feature(public,docs): new public website and docs

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-11-13 21:15:46 +01:00
parent fc2a019e1d
commit a022cb4831
234 changed files with 9341 additions and 6154 deletions

View File

@@ -19,30 +19,26 @@ const ConnectApp = ({ client }: Props) => {
Pick a framework below to get started.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks.app.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<img
className="h-full w-full object-contain"
src={framework.logo}
alt={framework.name}
width={32}
height={32}
/>
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
{frameworks
.filter((framework) => framework.type.includes('app'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}

View File

@@ -19,30 +19,26 @@ const ConnectBackend = ({ client }: Props) => {
Pick a framework below to get started.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks.backend.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<img
className="h-full w-full object-contain"
src={framework.logo}
alt={framework.name}
width={32}
height={32}
/>
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
{frameworks
.filter((framework) => framework.type.includes('backend'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}

View File

@@ -19,30 +19,26 @@ const ConnectWeb = ({ client }: Props) => {
Pick a framework below to get started.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{frameworks.website.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<img
className="h-full w-full object-contain"
src={framework.logo}
alt={framework.name}
width={32}
height={32}
/>
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
{frameworks
.filter((framework) => framework.type.includes('website'))
.map((framework) => (
<button
type="button"
onClick={() =>
pushModal('Instructions', {
framework,
client,
})
}
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<framework.IconComponent className="h-full w-full" />
</div>
<div className="flex-1 font-semibold">{framework.name}</div>
</button>
))}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}

View File

@@ -18,8 +18,10 @@ import superjson from 'superjson';
import { NotificationProvider } from '@/components/notifications/notification-provider';
import { OpenPanelComponent } from '@openpanel/nextjs';
import { useSearchParams } from 'next/navigation';
function AllProviders({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const { getToken } = useAuth();
const [queryClient] = useState(
() =>
@@ -59,11 +61,16 @@ function AllProviders({ children }: { children: React.ReactNode }) {
storeRef.current = makeStore();
}
const forcedTheme = searchParams.get('colorScheme');
return (
<ThemeProvider
attribute="class"
defaultTheme="light"
disableTransitionOnChange
defaultTheme="system"
forcedTheme={
forcedTheme ? (forcedTheme === 'dark' ? 'dark' : 'light') : 'system'
}
>
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
<OpenPanelComponent

View File

@@ -216,17 +216,6 @@ export function Chart({
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-mono">{step.count}</span>
{/* <button type="button"
className="ml-2 underline"
onClick={() =>
pushModal('FunnelStepDetails', {
...input,
step: index + 1,
})
}
>
Inspect
</button> */}
</div>
</div>
</TooltipComplete>

View File

@@ -1,103 +0,0 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { Pagination } from '@/components/pagination';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { DialogContent } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltiper } from '@/components/ui/tooltip';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import { api } from '@/trpc/client';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import type { IChartInput } from '@openpanel/validation';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
interface Props extends IChartInput {
step: number;
}
function usePrevious(value: any) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export default function FunnelStepDetails(props: Props) {
const [data] = api.chart.funnelStep.useSuspenseQuery(props);
const pathname = usePathname();
const prev = usePrevious(pathname);
const { organizationSlug, projectId } = useAppParams();
const [page, setPage] = useState(0);
useEffect(() => {
if (prev && prev !== pathname) {
popModal();
}
}, [pathname]);
return (
<DialogContent className="p-0">
<div className="p-4">
<ModalHeader title="Profiles" />
<Pagination
count={data.length}
take={50}
cursor={page}
setCursor={setPage}
/>
</div>
<ScrollArea className="max-h-[60vh]">
<WidgetTable
data={data.slice(page * 50, page * 50 + 50)}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
render(profile) {
return (
<Link
href={`/${organizationSlug}/${projectId}/profiles/${profile.id}`}
className="flex items-center gap-2 font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</Link>
);
},
},
{
name: '',
render(profile) {
return <ListPropertiesIcon {...profile.properties} />;
},
},
{
name: 'Last seen',
render(profile) {
return (
<Tooltiper
asChild
content={profile.createdAt.toLocaleString()}
>
<div className=" text-muted-foreground">
{profile.createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
);
},
},
]}
/>
</ScrollArea>
</DialogContent>
);
}

View File

@@ -16,10 +16,7 @@ import { popModal } from '.';
type Props = {
client: IServiceClient | null;
framework:
| (typeof frameworks.website)[number]
| (typeof frameworks.app)[number]
| (typeof frameworks.backend)[number];
framework: (typeof frameworks)[number];
};
const Header = ({ framework }: Pick<Props, 'framework'>) => (
@@ -29,7 +26,7 @@ const Header = ({ framework }: Pick<Props, 'framework'>) => (
);
const Footer = ({ framework }: Pick<Props, 'framework'>) => (
<SheetFooter>
<SheetFooter className="absolute bottom-0 left-0 right-0 p-4">
<Button
variant={'secondary'}
className="flex-1"
@@ -49,350 +46,21 @@ const Footer = ({ framework }: Pick<Props, 'framework'>) => (
</SheetFooter>
);
const Instructions = ({ framework, client }: Props) => {
const { name } = framework;
const clientId = client?.id || 'REPLACE_WITH_YOUR_CLIENT';
const clientSecret = client?.secret || 'REPLACE_WITH_YOUR_SECRET';
if (
name === 'HTML / Script' ||
name === 'React' ||
name === 'Astro' ||
name === 'Remix' ||
name === 'Vue'
) {
return (
<div className="flex flex-col gap-4">
<p>Copy the code below and insert it to you website</p>
<Syntax
code={`<script>
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
window.op('init', {
clientId: '${clientId}',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>`}
/>
<Alert>
<AlertDescription>
We have already added your client id to the snippet.
</AlertDescription>
</Alert>
</div>
);
}
if (name === 'Next.js') {
return (
<div className="flex flex-col gap-4">
<p>Install dependencies</p>
<Syntax code={'pnpm install @openpanel/nextjs'} />
<p>Add OpenPanelComponent to your root layout</p>
<Syntax
code={`import { OpenPanelComponent } from '@openpanel/nextjs';
export default RootLayout({ children }) {
return (
<>
<OpenPanelComponent
clientId="${clientId}"
trackScreenViews={true}
trackAttributes={true}
trackOutgoingLinks={true}
// If you have a user id, you can pass it here to identify the user
// profileId={'123'}
/>
{children}
</>
)
}`}
/>
<p>
This will track regular page views and outgoing links. You can also
track custom events.
</p>
<Syntax
code={`import { useOpenPanel } from '@openpanel/nextjs';
// Sends an event with payload foo: bar
useOpenPanel().track('my_event', { foo: 'bar' });
`}
/>
</div>
);
}
if (name === 'Laravel') {
return (
<div className="flex flex-col gap-4">
<p>Install dependencies</p>
<Syntax code={'composer require bleckert/openpanel-laravel'} />
<p>Add environment variables</p>
<Syntax
code={`OPENPANEL_CLIENT_ID=${clientId}
OPENPANEL_CLIENT_SECRET=${clientSecret}`}
/>
<p>Usage</p>
<Syntax
code={`use Bleckert\\OpenpanelLaravel\\Openpanel;
$openpanel = app(Openpanel::class);
// Identify user
$openpanel->setProfileId(1);
// Update user profile
$openpanel->setProfile(
id: 1,
firstName: 'John Doe',
// ...
);
// Track event
$openpanel->event(
name: 'User registered',
);
`}
/>
<Alert>
<AlertTitle>Shoutout!</AlertTitle>
<AlertDescription>
Huge shoutout to{' '}
<a
href="https://twitter.com/tbleckert"
target="_blank"
className="underline"
rel="noreferrer"
>
@tbleckert
</a>{' '}
for creating this package.
</AlertDescription>
</Alert>
</div>
);
}
if (name === 'Rest API') {
return (
<div className="flex flex-col gap-4">
<strong>Authentication</strong>
<p>You will need to pass your client ID and secret via headers.</p>
<strong>Usage</strong>
<p>Create a custom event called &quot;my_event&quot;.</p>
<Syntax
code={`curl '${process.env.NEXT_PUBLIC_API_URL}/track' \\
-H 'content-type: application/json' \\
-H 'openpanel-client-id: ${clientId}' \\
-H 'openpanel-client-secret: ${clientSecret}' \\
--data-raw '{
"type": "track",
"payload": {
"name": "my_event",
"properties": {
"foo": "bar"
}
}
}'`}
/>
<p>The payload should be a JSON object with the following fields:</p>
<ul className="list-inside list-disc">
<li>
&quot;type&quot; (string): track | identify | alias | increment |
decrement
</li>
<li>&quot;payload.name&quot; (string): The name of the event.</li>
<li>
&quot;payload.properties&quot; (object): The properties of the
event.
</li>
</ul>
</div>
);
}
if (name === 'Express') {
return (
<div className="flex flex-col gap-4">
<strong>Install dependencies</strong>
<Syntax code={'npm install @openpanel/express'} />
<strong>Usage</strong>
<p>Connect the middleware to your app.</p>
<Syntax
code={`import express from 'express';
import createOpenpanelMiddleware from '@openpanel/express';
const app = express();
app.use(
createOpenpanelMiddleware({
clientId: '${clientId}',
clientSecret: '${clientSecret}',
// trackRequest(url) {
// return url.includes('/v1')
// },
// getProfileId(req) {
// return req.user.id
// }
})
);
app.get('/sign-up', (req, res) => {
// track sign up events
req.op.track('sign-up', {
email: req.body.email,
});
res.send('Hello World');
});
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});`}
/>
</div>
);
}
if (name === 'Node') {
return (
<div className="flex flex-col gap-4">
<strong>Install dependencies</strong>
<Syntax code={'pnpm install @openpanel/sdk'} />
<strong>Create a instance</strong>
<p>
Create a new instance of OpenPanel. You can use this SDK in any JS
environment. You should omit clientSecret if you use this on web!
</p>
<Syntax
code={`import { OpenPanel } from '@openpanel/sdk';
const op = new OpenPanel({
clientId: '${clientId}',
// mostly for backend and apps that can't rely on CORS
clientSecret: '${clientSecret}',
});`}
/>
<strong>Usage</strong>
<Syntax
code={`import { op } from './openpanel';
// Sends an event with payload foo: bar
op.track('my_event', { foo: 'bar' });
// Identify with profile id
op.identify({ profileId: '123' });
// or with additional data
op.identify({
profileId: '123',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@openpanel.dev',
});
// Increment a property
op.increment({ name: 'app_opened', profile_id: '123' }); // increment by 1
op.increment({ name: 'app_opened', profile_id: '123', value: 5 }); // increment by 5
// Decrement a property
op.decrement({ name: 'app_opened', profile_id: '123' }); // decrement by 1
op.decrement({ name: 'app_opened', profile_id: '123', value: 5 }); // decrement by 5`}
/>
</div>
);
}
if (name === 'React-Native') {
return (
<div className="flex flex-col gap-4">
<strong>Install dependencies</strong>
<p>Don&apos;t forget to install the peer dependencies as well!</p>
<Syntax
code={`pnpm install @openpanel/react-native
npx expo install --pnpm expo-application expo-constants`}
/>
<strong>Create a instance</strong>
<p>
Create a new instance of OpenpanelSdk. You can use this SDK in any JS
environment. You should omit clientSecret if you use this on web!
</p>
<Syntax
code={`import { OpenPanel } from '@openpanel/react-native';
const op = new OpenPanel({
clientId: '${clientId}',
clientSecret: '${clientSecret}',
});`}
/>
<strong>Usage</strong>
<Syntax
code={`import { op } from './openpanel';
// Sends an event with payload foo: bar
op.track('my_event', { foo: 'bar' });
// Identify with profile id
op.identify({ profileId: '123' });
// or with additional data
op.identify({
profileId: '123',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@openpanel.dev',
});
// Increment a property
op.increment({ name: 'app_opened', profile_id: '123' }); // increment by 1
op.increment({ name: 'app_opened', profile_id: '123', value: 5 }); // increment by 5
// Decrement a property
op.decrement({ name: 'app_opened', profile_id: '123' }); // decrement by 1
op.decrement({ name: 'app_opened', profile_id: '123', value: 5 }); // decrement by 5`}
/>
<strong>Navigation</strong>
<p>
Check out our{' '}
<a
href="https://github.com/Openpanel-dev/examples/tree/main/expo-app"
target="_blank"
className="underline"
rel="noreferrer"
>
example app
</a>{' '}
. See below for a quick demo.
</p>
<Syntax
code={`function RootLayoutNav() {
const pathname = usePathname()
useEffect(() => {
op.screenView(pathname)
}, [pathname])
const Instructions = ({ framework }: Props) => {
return (
<Stack>
{/*... */}
</Stack>
<iframe
className="w-full h-full"
src={framework.href}
title={framework.name}
/>
);
}`}
/>
</div>
);
}
};
export default function InsdtructionsWithModalContent(props: Props) {
export default function InstructionsWithModalContent(props: Props) {
return (
<SheetContent>
<Header framework={props.framework} />
<SheetContent className="p-0">
<Instructions {...props} />
<Footer framework={props.framework} />
<Footer {...props} />
</SheetContent>
);
}

View File

@@ -62,9 +62,6 @@ const modals = {
VerifyEmail: dynamic(() => import('./VerifyEmail'), {
loading: Loading,
}),
FunnelStepDetails: dynamic(() => import('./FunnelStepDetails'), {
loading: Loading,
}),
DateRangerPicker: dynamic(() => import('./DateRangerPicker'), {
loading: Loading,
}),

39
apps/docs/.gitignore vendored
View File

@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo

View File

@@ -1 +0,0 @@
./src/pages/docs/*

View File

@@ -1,59 +0,0 @@
FROM --platform=linux/amd64 node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
ARG NODE_VERSION=20.15.1
RUN apt update \
&& apt install -y curl \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
WORKDIR /app
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/docs/package.json apps/docs/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/docs
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps apps
RUN mkdir packages
COPY tooling tooling
WORKDIR /app/apps/docs
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app/apps/docs
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/docs /app/apps/docs
# Apps node_modules
COPY --from=prod /app/apps/docs/node_modules /app/apps/docs/node_modules
WORKDIR /app/apps/docs
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@@ -1 +0,0 @@
# Docs

View File

@@ -1,33 +0,0 @@
import nextra from 'nextra';
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
transpilePackages: ['@openpanel/queue'],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
// Avoid "Critical dependency: the request of a dependency is an expression"
serverComponentsExternalPackages: ['bullmq'],
},
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ['en-US'],
defaultLocale: 'en-US',
},
};
const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
flexsearch: {
codeblocks: false,
},
defaultShowCopyCode: true,
});
export default withNextra(config);

View File

@@ -1,29 +0,0 @@
{
"name": "@openpanel/docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "rm -rf .next && pnpm with-env next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"next": "~14.2.1",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"@types/node": "^18.16.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

View File

@@ -1,15 +0,0 @@
interface Props {
src: string;
isDark?: boolean;
}
export function BrandLogo({ src, isDark }: Props) {
if (isDark) {
return (
<div className="h-9 w-9 rounded-full bg-white p-1">
<img className="h-full w-full object-contain" src={src} />
</div>
);
}
return <img className="h-9 w-9 object-contain" src={src} />;
}

View File

@@ -1,7 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
article main a {
@apply underline;
}

View File

@@ -1,40 +0,0 @@
import type { AppProps } from 'next/app';
import Head from 'next/head';
import Script from 'next/script';
import 'src/globals.css';
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<link rel="icon" href="/favicon.ico" />
</Head>
<Component {...pageProps} />
<Script
src="https://openpanel.dev/op1.js"
async
defer
strategy="afterInteractive"
/>
<Script
id="openpanel"
dangerouslySetInnerHTML={{
__html: `
window.op =
window.op ||
function (...args) {
(window.op.q = window.op.q || []).push(args);
};
window.op('ctor', {
clientId: '301c6dc1-424c-4bc3-9886-a8beab09b615',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
`,
}}
/>
</>
);
}

View File

@@ -1,10 +0,0 @@
{
"index": {
"title": "Home",
"type": "page"
},
"docs": {
"title": "Documentation",
"type": "page"
}
}

View File

@@ -1,6 +0,0 @@
{
"index": "Get Started",
"sdks": "SDKs",
"migration": "Migrations",
"self-hosting": "Self-hosting"
}

View File

@@ -1,167 +0,0 @@
import { Callout, Card, Cards } from 'nextra/components';
import { BrandLogo } from 'src/components/brand-logo';
# Documentation
The OpenPanel SDKs provide a set of core methods that allow you to track events, identify users, and more. Here's an overview of the key methods available in the SDKs.
<Callout>
While all OpenPanel SDKs share a common set of core methods, some may have
syntax variations or additional methods specific to their environment. This
documentation provides an overview of the base methods and available SDKs.
</Callout>
## Core Methods
### setGlobalProperties
Sets global properties that will be included with every subsequent event.
### track
Tracks a custom event with the given name and optional properties.
**Tips**
You can identify the user directly with this method.
```js filename="Example shown in JavaScript"
track('your_event_name', {
foo: 'bar',
baz: 'qux',
// reserved property name
__identify: {
profileId: 'your_user_id', // required
email: 'your_user_email',
firstName: 'your_user_name',
lastName: 'your_user_name',
avatar: 'your_user_avatar',
}
});
```
### identify
Associates the current user with a unique identifier and optional traits.
### alias
Creates an alias for a user identifier.
### increment
Increments a numeric property for a user.
### decrement
Decrements a numeric property for a user.
### clear
Clears the current user identifier and ends the session.
## Official SDKs
<Cards>
<Card
icon={
<BrandLogo src="https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/HTML5_logo_and_wordmark.svg/240px-HTML5_logo_and_wordmark.svg.png" />
}
title="HTML / Script"
href="/docs/sdks/script"
>
{' '}
</Card>
<Card
icon={
<BrandLogo src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png" />
}
title="React"
href="/docs/sdks/react"
>
{' '}
</Card>
<Card
icon={
<BrandLogo src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png" />
}
title="React-Native"
href="/docs/sdks/react-native"
>
{' '}
</Card>
<Card
icon={
<BrandLogo
isDark
src="https://pbs.twimg.com/profile_images/1565710214019444737/if82cpbS_400x400.jpg"
/>
}
title="Next.js"
href="/docs/sdks/nextjs"
>
{' '}
</Card>
<Card
icon={
<BrandLogo
isDark
src="https://www.datocms-assets.com/205/1642515307-square-logo.svg"
/>
}
title="Remix"
href="/docs/sdks/remix"
>
{' '}
</Card>
<Card
icon={
<BrandLogo src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Vue.js_Logo_2.svg/1024px-Vue.js_Logo_2.svg.png" />
}
title="Vue"
href="/docs/sdks/vue"
>
{' '}
</Card>
<Card
icon={
<BrandLogo
isDark
src="https://astro.build/assets/press/astro-icon-dark.png"
/>
}
title="Astro"
href="/docs/sdks/astro"
>
{' '}
</Card>
</Cards>
## Unofficial SDKs
While not officially supported, the following community-contributed SDKs are available.
<Cards>
<Card
icon={
<BrandLogo
src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Laravel.svg/1200px-Laravel.svg.png"
/>
}
title="Laravel"
href="https://github.com/tbleckert/openpanel-laravel"
>
{' '}
</Card>
<Card
icon={
<BrandLogo
src="https://storage.googleapis.com/cms-storage-bucket/0dbfcc7a59cd1cf16282.png"
/>
}
title="Flutter"
href="https://github.com/stevenosse/openpanel_flutter"
>
{' '}
</Card>
</Cards>

View File

@@ -1,3 +0,0 @@
{
"beta-v1": "Beta to v1"
}

View File

@@ -1,19 +0,0 @@
{
"script": "Web (Script Tag)",
"web": "Web (Module)",
"nextjs": "Next.js",
"react": "React",
"react-native": "React-Native",
"remix": "Remix",
"vue": "Vue",
"astro": "Astro",
"node": "Node",
"express": "Express (backend)",
"-- Others": {
"type": "separator",
"title": "Others"
},
"javascript": "JavaScript",
"api": "API",
"export": "Export"
}

View File

@@ -1,5 +0,0 @@
import Link from 'next/link';
# Astro
You can use <Link href="/docs/sdks/script">script tag</Link> or <Link href="/docs/sdks/web">Web SDK</Link> to track events in Astro.

View File

@@ -1,5 +0,0 @@
import Link from 'next/link';
# Node
Use <Link href="/docs/sdks/javascript">Javascript SDK</Link> to track events in Node.

View File

@@ -1,5 +0,0 @@
import Link from 'next/link';
# React
Use <Link href="/docs/sdks/script">script tag</Link> or <Link href="/docs/sdks/web">Web SDK</Link> for now. We'll add a dedicated react sdk soon.

View File

@@ -1,5 +0,0 @@
import Link from 'next/link';
# Remix
Use <Link href="/docs/sdks/script">script tag</Link> or <Link href="/docs/sdks/web">Web SDK</Link> for now. We'll add a dedicated remix sdk soon.

View File

@@ -1,5 +0,0 @@
import Link from 'next/link';
# Vue
Use <Link href="/docs/sdks/script">script tag</Link> or <Link href="/docs/sdks/web">Web SDK</Link> for now. We'll add a dedicated react sdk soon.

View File

@@ -1,3 +0,0 @@
{
"index": "Get started"
}

View File

@@ -1,16 +0,0 @@
<img src="https://openpanel.dev/ogimage.png" />
# Introduction
Openpanel is an open-source alternative to Mixpanel. Combining the power of Mixpanel with the ease of Plausible, Openpanel is a privacy-focused analytics tool that gives you the insights you need to make data-driven decisions.
## Features
- ✅ **Privacy-focused**: Openpanel is built with privacy in mind. We don't track any personal data and we don't use cookies.
- ✅ **Open-source**: Openpanel is open-source and you can host it yourself.
- ✅ **Cloud-hosted**: You can choose our cloud hosting if you don't want to host it yourself. We take care of the infrastructure and you can focus on your business.
- ✅ **Real-time analytics**: Everything is updated in real-time, no delays to see your insights.
- ✅ **Event-based tracking**: You can track any event you want, and you can track as many events as you want.
- ✅ **Custom properties**: You can add custom properties to your events to track more data.
- ✅ **Funnel analysis**: You can create funnels to see how your users are interacting with your product.
- ✅ **Reports**: Create as many reports as you want to visualize the data you need.

View File

@@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -1,85 +0,0 @@
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useConfig } from 'nextra-theme-docs';
export default {
banner: {
key: '1.0-release',
text: (
<a href="/docs/migration/beta-v1">
🎉 We have released v1. Read migration guide if needed!
</a>
),
},
logo: (
<>
<Image
src="https://dashboard.openpanel.dev/logo.svg"
alt="next-international logo"
height="32"
width="32"
/>
<strong style={{ marginLeft: '8px' }}>OpenPanel</strong>
</>
),
head: () => {
const router = useRouter();
const config = useConfig();
const title = config.title;
const description = 'An open-source alternative to Mixpanel';
const domain = 'https://docs.openpanel.dev';
const canonicalUrl =
`${domain}${router.asPath === '/' ? '' : router.asPath}`.split('?')[0];
return (
<>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:site:domain"
content={domain.replace('https://', '')}
/>
<meta name="twitter:url" content={domain} />
<meta name="og:type" content={'site'} />
<meta name="og:url" content={canonicalUrl} />
<meta name="og:title" content={`${title} - Openpanel Docs`} />
<meta property="og:description" content={description} />
<meta
name="og:image"
content={'https://docs.openpanel.dev/ogimage.png'}
/>
<meta name="title" content={title} />
<meta name="description" content={description} />
</>
);
},
useNextSeoProps() {
return {
titleTemplate: '%s - Openpanel Docs',
};
},
search: {
placeholder: 'Search',
},
project: {
link: 'https://github.com/openpanel-dev/openpanel',
},
docsRepositoryBase:
'https://github.com/openpanel-dev/openpanel/blob/main/apps/docs',
footer: {
text: (
<span>
Made with by{' '}
<a
href="https://x.com/OpenPanelDev"
target="_blank"
rel="noreferrer nofollow"
>
Carl
</a>
</span>
),
},
};

View File

@@ -1,18 +0,0 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
}
],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"strictNullChecks": true
},
"include": [".", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,39 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
# deps
/node_modules
/.pnp
.pnp.js
# testing
# generated content
.contentlayer
.content-collections
.source
# test & build
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
# debug
/.pnp
.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
# others
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

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

View File

@@ -1,28 +1,26 @@
# Create T3 App
# public
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
This is a Next.js application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
## What's next? How do I make an app with this?
Run development server:
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
Open http://localhost:3000 with your browser to see the result.
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
To learn more about Next.js and Fumadocs, take a look at the following
resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs

View File

@@ -0,0 +1,96 @@
import { url } from '@/app/layout.config';
import { pageSource } from '@/lib/source';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
export async function generateMetadata({
params,
}: {
params: { pages: string[] };
}): Promise<Metadata> {
const { pages } = await params;
const page = await pageSource.getPage(pages);
if (!page) {
return {
title: 'Page Not Found',
};
}
return {
title: page.data.title,
description: page.data.description,
alternates: {
canonical: url(page.url),
},
openGraph: {
title: page.data.title,
description: page.data.description,
type: 'website',
url: url(page.url),
},
twitter: {
card: 'summary_large_image',
title: page.data.title,
description: page.data.description,
},
};
}
export default async function Page({
params,
}: {
params: { pages: string[] };
}) {
const { pages } = await params;
const page = await pageSource.getPage(pages);
const Body = page?.data.body;
if (!page || !Body) {
return notFound();
}
// Create the JSON-LD data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: page.data.title,
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url(page.url),
},
};
return (
<div>
<Script
id="page-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="container max-w-4xl col">
<div className="py-16 col gap-3">
<h1 className="text-5xl font-bold">{page.data.title}</h1>
{page.data.description && (
<p className="text-muted-foreground text-xl">
{page.data.description}
</p>
)}
</div>
<div className="prose">
<Body />
</div>
</article>
</div>
);
}

View File

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

View File

@@ -0,0 +1,72 @@
import { url } from '@/app/layout.config';
import { articleSource } from '@/lib/source';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
const title = 'Articles';
const description = 'Read our latest articles';
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: url('/articles'),
},
openGraph: {
title,
description,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description,
},
};
export default async function Page() {
const articles = (await articleSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
return (
<div>
<div className="container col">
<div className="py-16">
<h1 className="text-center text-7xl font-bold">Articles</h1>
</div>
<div className="grid grid-cols-3 gap-8">
{articles.map((item) => (
<Link
href={item.url}
key={item.url}
className="border rounded-lg overflow-hidden bg-background-light col hover:scale-105 transition-all duration-300 hover:shadow-lg hover:shadow-background-dark"
>
<Image
src={item.data.cover}
alt={item.data.title}
width={323}
height={181}
/>
<span className="p-4 col flex-1">
{item.data.tag && (
<span className="font-mono text-xs mb-2">
{item.data.tag}
</span>
)}
<span className="flex-1 mb-6">
<h2 className="text-xl font-semibold">{item.data.title}</h2>
</span>
<p className="text-sm text-muted-foreground">
{[item.data.team, item.data.date.toLocaleDateString()]
.filter(Boolean)
.join(' · ')}
</p>
</span>
</Link>
))}
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import { source } from '@/lib/source';
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import defaultMdxComponents from 'fumadocs-ui/mdx';
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

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

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

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import type { MetadataRoute } from 'next';
import { defaultMeta } from './meta';
import { metadata } from './layout';
export default function manifest(): MetadataRoute.Manifest {
return {
name: defaultMeta.title as string,
name: metadata.title as string,
short_name: 'Openpanel.dev',
description: defaultMeta.description!,
description: metadata.description!,
start_url: '/',
display: 'standalone',
background_color: '#fff',

View File

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

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

@@ -0,0 +1,28 @@
import { Hero } from '@/components/hero';
import { Faq } from '@/components/sections/faq';
import { Features } from '@/components/sections/features';
import { Pricing } from '@/components/sections/pricing';
import { Sdks } from '@/components/sections/sdks';
import { Stats } from '@/components/sections/stats';
import { Testimonials } from '@/components/sections/testimonials';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'An open-source alternative to Mixpanel',
};
export const revalidate = 3600;
export default function HomePage() {
return (
<main>
<Hero />
<Features />
<Testimonials />
<Stats />
<Faq />
<Pricing />
<Sdks />
</main>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
import { cn } from '@/lib/utils';
interface SwirlProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
export function SingleSwirl({ className, ...props }: SwirlProps) {
return (
<svg
width="1193"
height="634"
viewBox="0 0 1193 634"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('text-white', className)}
{...props}
>
<g filter="url(#filter0_f_290_140)">
<path
d="M996.469 546.016C728.822 501.422 310.916 455.521 98.1817 18.6728"
stroke="currentColor"
strokeWidth="26"
/>
</g>
<g filter="url(#filter1_f_290_140)">
<path
d="M780.821 634.792C582.075 610.494 151.698 468.051 20.1495 92.6602"
stroke="currentColor"
/>
</g>
<defs>
<filter
id="filter0_f_290_140"
x="-107.406"
y="-180.919"
width="1299.91"
height="933.658"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="96.95"
result="effect1_foregroundBlur_290_140"
/>
</filter>
<filter
id="filter1_f_290_140"
x="-3.32227"
y="69.4946"
width="807.204"
height="588.793"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="11.5"
result="effect1_foregroundBlur_290_140"
/>
</filter>
</defs>
</svg>
);
}
export function DoubleSwirl({ className, ...props }: SwirlProps) {
return (
<svg
width="1535"
height="1178"
viewBox="0 0 1535 1178"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('text-white', className)}
{...props}
>
<g filter="url(#filter0_f_290_639)">
<path
d="M1392.59 1088C1108.07 603.225 323.134 697.532 143.435 135.494"
stroke="currentColor"
strokeOpacity="0.5"
strokeWidth="26"
/>
</g>
<g filter="url(#filter1_f_290_639)">
<path
d="M1446.57 1014.51C1162.05 529.732 377.111 624.039 197.412 62.0001"
stroke="currentColor"
strokeOpacity="0.06"
strokeWidth="26"
/>
</g>
<defs>
<filter
id="filter0_f_290_639"
x="0.244919"
y="0.679001"
width="1534.37"
height="1224.74"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="65.4"
result="effect1_foregroundBlur_290_639"
/>
</filter>
<filter
id="filter1_f_290_639"
x="160.022"
y="32.9856"
width="1322.77"
height="1013.14"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="12.5"
result="effect1_foregroundBlur_290_639"
/>
</filter>
</defs>
</svg>
);
}

View File

@@ -1,5 +1,5 @@
import { Callout } from 'fumadocs-ui/components/callout';
import Link from 'next/link';
import { Callout } from 'nextra/components';
export function DeviceIdWarning() {
return (

View File

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

View File

@@ -0,0 +1,22 @@
import Image from 'next/image';
export function Figure({
src,
alt,
caption,
}: { src: string; alt: string; caption: string }) {
return (
<figure className="-mx-4">
<Image
src={src}
alt={alt}
width={1200}
height={800}
className="rounded-lg"
/>
<figcaption className="text-center text-sm text-muted-foreground mt-2">
{caption}
</figcaption>
</figure>
);
}

View File

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

View File

@@ -0,0 +1,39 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Button } from './ui/button';
function formatStars(stars: number) {
if (stars >= 1000) {
const k = stars / 1000;
return `${k.toFixed(k >= 10 ? 0 : 1)}k`;
}
return stars.toString();
}
export function GithubButton() {
const [stars, setStars] = useState(3_100);
// useEffect(() => {
// getGithubRepoInfo().then((res) => setStars(res.stargazers_count));
// }, []);
return (
<Button variant={'secondary'} asChild>
<Link href="https://git.new/openpanel">
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
{formatStars(stars)} stars
</Link>
</Button>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useIsDarkMode } from '@/lib/dark-mode';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
type Frame = {
id: string;
label: string;
key: string;
Component: React.ComponentType;
};
function LivePreview() {
const isDark = useIsDarkMode();
const colorScheme = isDark ? 'dark' : 'light';
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded) {
return null;
}
return (
<iframe
// src={`http://localhost:3000/share/overview/zef2XC?header=0&range=30d&colorScheme=${colorScheme}`}
src={`https://dashboard.openpanel.dev/share/overview/zef2XC?header=0&range=30d&colorScheme=${colorScheme}`}
className="w-full h-full"
title="Live preview"
scrolling="no"
/>
);
}
function Image({ src }: { src: string }) {
const isDark = useIsDarkMode();
const colorScheme = isDark ? 'dark' : 'light';
return (
<div>
<img
className="w-full h-full"
src={`/${src}-${colorScheme}.png`}
alt={src}
/>
</div>
);
}
export function HeroCarousel() {
const frames: Frame[] = [
{
id: 'overview',
key: 'overview',
label: 'Live preview',
Component: LivePreview,
},
{
id: 'analytics',
key: 'analytics',
label: 'Product analytics',
Component: () => <Image src="dashboard" />,
},
{
id: 'funnels',
key: 'funnels',
label: 'Funnels',
Component: () => <Image src="funnel" />,
},
{
id: 'retention',
key: 'retention',
label: 'Retention',
Component: () => <Image src="retention" />,
},
{
id: 'profile',
key: 'profile',
label: 'Inspect profile',
Component: () => <Image src="profile" />,
},
];
const [activeFrames, setActiveFrames] = useState<Frame[]>([frames[0]]);
const activeFrame = activeFrames[activeFrames.length - 1];
return (
<div className="col gap-6 w-full">
<div className="row gap-4 justify-center [&>div]:font-medium mt-1">
{frames.map((frame) => (
<div key={frame.id} className="relative">
<Button
variant="naked"
type="button"
onClick={() => {
if (activeFrame.id === frame.id) {
return;
}
const newFrame = {
...frame,
key: Math.random().toString().slice(2, 11),
};
setActiveFrames((p) => [...p.slice(-2), newFrame]);
}}
className="relative"
>
{frame.label}
</Button>
<motion.div
className="h-1 bg-foreground rounded-full"
initial={false}
animate={{
width: activeFrame.id === frame.id ? '100%' : '0%',
opacity: activeFrame.id === frame.id ? 1 : 0,
}}
whileHover={{
width: '100%',
opacity: 0.5,
}}
transition={{ duration: 0.2, ease: 'easeInOut' }}
/>
</div>
))}
</div>
<div className="pulled animated-iframe-gradient p-px pb-0 rounded-t-xl">
<div className="overflow-hidden rounded-xl rounded-b-none w-full bg-background">
<div className="relative w-full h-[750px]">
<AnimatePresence mode="popLayout" initial={false}>
{activeFrames.slice(-2).map((frame) => (
<motion.div
key={frame.key}
layout
className="absolute inset-0 w-full h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: 'easeIn' }}
>
<div className="bg-background rounded-xl h-full w-full">
<frame.Component />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,66 @@
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { HeroCarousel } from './hero-carousel';
import { HeroMap } from './hero-map';
import { Button } from './ui/button';
import { WorldMap } from './world-map';
export function Hero() {
return (
<HeroContainer>
{/* Shadow bottom */}
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent z-20" />
{/* Content */}
<div className="container relative z-10">
<div className="max-w-2xl col gap-4 pt-28 text-center mx-auto ">
<h1 className="text-6xl font-bold leading-[1.1] animate-fade-up">
An open-source alternative to <span>Mixpanel</span>
</h1>
<p className="text-xl text-muted-foreground animate-fade-up">
The power of Mixpanel, the ease of Plausible and nothing from Google
Analytics 😉
</p>
</div>
{/* CTA */}
<div className="row gap-4 center-center my-12 animate-fade-up">
<Button size="lg" asChild>
<Link href="https://dashboard.openpanel.dev/register">
Try it for free
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Free for 30 days, no credit card required
</p>
</div>
<HeroCarousel />
</div>
</HeroContainer>
);
}
export function HeroContainer({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}): React.ReactElement {
return (
<section className={cn('radial-gradient overflow-hidden relative')}>
{/* Map */}
<HeroMap />
{/* Gradient over map */}
<div className="absolute inset-0 radial-gradient-dot-1 select-none" />
<div className="absolute inset-0 radial-gradient-dot-1 select-none" />
<div className={cn('relative z-10', className)}>{children}</div>
{/* Shadow bottom */}
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
</section>
);
}

View File

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

View File

@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
export function Logo({ className }: { className?: string }) {
return (
<svg
width="61"
height="35"
viewBox="0 0 61 35"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn('text-black dark:text-white', className)}
>
<rect
x="34.0269"
y="0.368164"
width="10.3474"
height="34.2258"
rx="5.17372"
/>
<rect
x="49.9458"
y="0.368164"
width="10.3474"
height="17.5109"
rx="5.17372"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.212 0C6.36293 0 0 6.36293 0 14.212V20.02C0 27.8691 6.36293 34.232 14.212 34.232C22.0611 34.232 28.424 27.8691 28.424 20.02V14.212C28.424 6.36293 22.0611 0 14.212 0ZM14.2379 8.35999C11.3805 8.35999 9.06419 10.6763 9.06419 13.5337V20.6971C9.06419 23.5545 11.3805 25.8708 14.2379 25.8708C17.0953 25.8708 19.4116 23.5545 19.4116 20.6971V13.5337C19.4116 10.6763 17.0953 8.35999 14.2379 8.35999Z"
/>
</svg>
);
}

View File

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

View File

@@ -1,8 +1,8 @@
import { Callout } from 'nextra/components';
import { Callout } from 'fumadocs-ui/components/callout';
export function PersonalDataWarning() {
return (
<Callout emoji="⚠️">
<Callout>
Keep in mind that this is considered personal data. Make sure you have the
users consent before calling this!
</Callout>

View File

@@ -0,0 +1,59 @@
'use client';
import NumberFlow from '@number-flow/react';
import { useState } from 'react';
import { Slider } from './ui/slider';
const PRICING = [
{ price: 0, events: 5_000 },
{ price: 5, events: 10_000 },
{ price: 20, events: 100_000 },
{ price: 30, events: 250_000 },
{ price: 50, events: 500_000 },
{ price: 90, events: 1_000_000 },
{ price: 180, events: 2_500_000 },
{ price: 250, events: 5_000_000 },
{ price: 400, events: 10_000_000 },
// { price: 650, events: 20_000_000 },
// { price: 900, events: 30_000_000 },
];
export function PricingSlider() {
const [index, setIndex] = useState(2);
const match = PRICING[index];
const formatNumber = (value: number) => value.toLocaleString();
return (
<>
<Slider
value={[index]}
max={PRICING.length}
step={1}
tooltip={
match
? `${formatNumber(match.events)} events`
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
}
onValueChange={(value) => setIndex(value[0])}
/>
{match ? (
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}}
locales={'en-US'}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
</div>
) : (
<div>Contact us hello@openpanel.dev</div>
)}
</>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,235 @@
import {
Feature,
FeatureContent,
FeatureList,
FeatureMore,
} from '@/components/feature';
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import {
AreaChartIcon,
BarChart2Icon,
BarChartIcon,
BatteryIcon,
ClockIcon,
CloudIcon,
ConeIcon,
CookieIcon,
DatabaseIcon,
LineChartIcon,
MapIcon,
PieChartIcon,
UserIcon,
} from 'lucide-react';
import { EventsFeature } from './features/events-feature';
import { ProductAnalyticsFeature } from './features/product-analytics-feature';
import { ProfilesFeature } from './features/profiles-feature';
import { WebAnalyticsFeature } from './features/web-analytics-feature';
export function Features() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
tag={
<Tag>
<BatteryIcon className="size-4" strokeWidth={1.5} />
Batteries included
</Tag>
}
title="Everything you need"
description="We have combined the best features from the most popular analytics tools into one simple to use platform."
/>
<div className="col gap-16">
<Feature media={<WebAnalyticsFeature />}>
<FeatureContent
title="Web analytics"
content={[
'Privacy-friendly analytics with all the important metrics you need, in a simple and modern interface.',
]}
/>
<FeatureList
className="mt-4"
title="Get a quick overview"
items={[
'• Visitors',
'• Referrals',
'• Top pages',
'• Top entries',
'• Top exists',
'• Devices',
'• Sessions',
'• Bounce rate',
'• Duration',
'• Geography',
]}
/>
{/* <FeatureMore href="#" className="mt-4">
And mouch more
</FeatureMore> */}
</Feature>
<Feature reverse media={<ProductAnalyticsFeature />}>
<FeatureContent
title="Product analytics"
content={[
'Turn data into decisions with powerful visualizations and real-time insights.',
]}
/>
<FeatureList
className="mt-4"
title="Supported charts"
items={[
<div className="row items-center gap-2" key="line">
<LineChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Line
</div>,
<div className="row items-center gap-2" key="bar">
<BarChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Bar
</div>,
<div className="row items-center gap-2" key="pie">
<PieChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Pie
</div>,
<div className="row items-center gap-2" key="area">
<AreaChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Area
</div>,
<div className="row items-center gap-2" key="histogram">
<BarChart2Icon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Histogram
</div>,
<div className="row items-center gap-2" key="map">
<MapIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Map
</div>,
<div className="row items-center gap-2" key="funnel">
<ConeIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Funnel
</div>,
<div className="row items-center gap-2" key="retention">
<UserIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Retention
</div>,
]}
/>
</Feature>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Feature>
<FeatureContent
icon={<ClockIcon className="size-8" strokeWidth={1} />}
title="Real time analytics"
content={[
'Get instant insights into your data. No need to wait for data to be processed, like other tools out there, looking at you GA4...',
]}
/>
</Feature>
<Feature>
<FeatureContent
icon={<DatabaseIcon className="size-8" strokeWidth={1} />}
title="Own your own data"
content={[
'Own your data, no vendor lock-in. Export your all your data or delete it any time',
]}
/>
</Feature>
<div />
<div />
<Feature>
<FeatureContent
icon={<CloudIcon className="size-8" strokeWidth={1} />}
title="Cloud or self-hosted"
content={[
'We offer a cloud version of the platform, but you can also self-host it on your own infrastructure.',
]}
/>
<FeatureMore href="#" className="mt-4 -mb-4">
More about self-hosting
</FeatureMore>
</Feature>
<Feature>
<FeatureContent
icon={<CookieIcon className="size-8" strokeWidth={1} />}
title="No cookies"
content={[
'We care about your privacy, so our tracker does not use cookies. This keeps your data safe and secure.',
'We follow GDPR guidelines closely, ensuring your personal information is protected without using invasive technologies.',
]}
/>
</Feature>
</div>
<Feature media={<EventsFeature />}>
<FeatureContent
title="Your events"
content={[
'Track every user interaction with powerful real-time event analytics. See all event properties, user actions, and conversion data in one place.',
'From pageviews to custom events, get complete visibility into how users actually use your product.',
]}
/>
<FeatureList
cols={1}
className="mt-4"
title="Some goodies"
items={[
'• Events arrive within seconds',
'• Filter on any property or attribute',
'• Get notified on important events',
'• Export and analyze event data',
'• Track user journeys and conversions',
]}
/>
</Feature>
<Feature reverse media={<ProfilesFeature />}>
<FeatureContent
title="Profiles and sessions"
content={[
'Get detailed insights into how users interact with your product through comprehensive profile and session tracking. See everything from basic metrics to detailed behavioral patterns.',
'Track session duration, page views, and user journeys to understand how people actually use your product.',
]}
/>
<FeatureList
cols={1}
className="mt-4"
title="What can you see?"
items={[
'• First and last seen dates',
'• Session duration and counts',
'• Page views and activity patterns',
'• User location and device info',
'• Browser and OS details',
'• Event history and interactions',
'• Real-time activity tracking',
]}
/>
</Feature>
</div>
</Section>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
import { CheckIcon, DollarSignIcon } from 'lucide-react';
import Link from 'next/link';
import { DoubleSwirl } from '../Swirls';
import { PricingSlider } from '../pricing-slider';
import { Section, SectionHeader } from '../section';
import { Tag } from '../tag';
import { Button } from '../ui/button';
export default Pricing;
export function Pricing() {
return (
<Section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
<DoubleSwirl className="absolute -top-32 left-0" />
<div className="container relative z-10">
<SectionHeader
tag={
<Tag variant={'dark'}>
<DollarSignIcon className="size-4" />
Simple and predictable
</Tag>
}
title="Simple pricing"
description="Our simple, usage-based pricing means you only pay for what you use. Scale effortlessly for the best value."
/>
<div className="grid md:grid-cols-[400px_1fr] gap-8">
<div className="col gap-4">
<h3 className="font-medium text-xl text-background/90 dark:text-foreground/90">
Stop overpaying <br />
for features
</h3>
<ul className="gap-1 col text-background/70 dark:text-foreground/70">
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />
Unlimited websites or apps
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited users
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited dashboards
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited charts
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited tracked profiles
</li>
</ul>
<Button variant="secondary" className="self-start mt-4" asChild>
<Link href="https://dashboard.openpanel.dev/register">
Start for free
</Link>
</Button>
</div>
<div className="col justify-between pt-14">
<PricingSlider />
<div className="text-sm text-muted-foreground">
<strong className="text-background/80 dark:text-foreground/80">
All features are included upfront - no hidden costs.
</strong>{' '}
You choose how many events to track each month. During the beta
phase, everything is offered for free to users.
</div>
</div>
</div>
</div>
</Section>
);
}

View File

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

View File

@@ -0,0 +1,111 @@
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import { cacheable } from '@openpanel/redis';
import Link from 'next/link';
import { Suspense } from 'react';
import { VerticalLine } from '../line';
import { PlusLine } from '../line';
import { HorizontalLine } from '../line';
import { Section } from '../section';
import { Button } from '../ui/button';
import { WorldMap } from '../world-map';
function shortNumber(num: number) {
if (num < 1e3) return num;
if (num >= 1e3 && num < 1e6) return `${+(num / 1e3).toFixed(1)}K`;
if (num >= 1e6 && num < 1e9) return `${+(num / 1e6).toFixed(1)}M`;
if (num >= 1e9 && num < 1e12) return `${+(num / 1e9).toFixed(1)}B`;
if (num >= 1e12) return `${+(num / 1e12).toFixed(1)}T`;
}
const getProjectsWithCount = cacheable(async function getProjectsWithCount() {
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()`,
);
const last24h = await chQuery<{ count: number }>(
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
);
return { projects, last24hCount: last24h[0]?.count || 0 };
}, 60 * 60);
export default Stats;
export function Stats() {
return (
<Suspense
fallback={<StatsPure projectCount={0} eventCount={0} last24hCount={0} />}
>
<StatsServer />
</Suspense>
);
}
export async function StatsServer() {
const { projects, last24hCount } = await getProjectsWithCount();
const projectCount = projects.length;
const eventCount = projects.reduce((acc, { count }) => acc + count, 0);
return (
<StatsPure
projectCount={projectCount}
eventCount={eventCount}
last24hCount={last24hCount}
/>
);
}
export function StatsPure({
projectCount,
eventCount,
last24hCount,
}: { projectCount: number; eventCount: number; last24hCount: number }) {
return (
<Section className="bg-gradient-to-b from-background via-background-dark to-background-dark py-64 pt-44 relative overflow-hidden -mt-16">
{/* Map */}
<div className="absolute inset-0 -top-20 center-center items-start select-none opacity-10">
<div className="min-w-[1400px] w-full">
<WorldMap />
{/* Gradient over Map */}
<div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
</div>
</div>
<div className="relative">
<HorizontalLine />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 container center-center">
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<PlusLine className="hidden lg:block top-0 left-0" />
<div className="text-muted-foreground text-xs">Active projects</div>
<div className="text-5xl font-bold font-mono">{projectCount}</div>
</div>
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<div className="text-muted-foreground text-xs">Total events</div>
<div className="text-5xl font-bold font-mono">
{shortNumber(eventCount)}
</div>
</div>
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<VerticalLine className="hidden lg:block right-0" />
<PlusLine className="hidden lg:block bottom-0 left-0" />
<div className="text-muted-foreground text-xs">
Events last 24 h
</div>
<div className="text-5xl font-bold font-mono">
{shortNumber(last24hCount)}
</div>
</div>
</div>
<HorizontalLine />
</div>
<div className="center-center col gap-4 absolute bottom-20 left-0 right-0 z-10">
<p>Get the analytics you deserve</p>
<Button asChild>
<Link href="https://dashboard.openpanel.dev/register">
Try it for free
</Link>
</Button>
</div>
</Section>
);
}

View File

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

View File

@@ -0,0 +1,70 @@
import { useMemo } from 'react';
interface SimpleChartProps {
width?: number;
height?: number;
points?: number[];
strokeWidth?: number;
strokeColor?: string;
className?: string;
}
export function SimpleChart({
width = 300,
height = 100,
points = [0, 10, 5, 8, 12, 4, 7],
strokeWidth = 2,
strokeColor = '#2563eb',
className,
}: SimpleChartProps) {
// Skip if no points
if (!points.length) return null;
// Calculate scaling factors
const maxValue = Math.max(...points);
const xStep = width / (points.length - 1);
const yScale = height / maxValue;
// Generate path commands
const pathCommands = points
.map((point, index) => {
const x = index * xStep;
const y = height - point * yScale;
return `${index === 0 ? 'M' : 'L'} ${x},${y}`;
})
.join(' ');
// Create area path by adding bottom corners
const areaPath = `${pathCommands} L ${width},${height} L 0,${height} Z`;
// Generate unique gradient ID
const gradientId = `gradient-${strokeColor
.replace('#', '')
.replaceAll('(', '')
.replaceAll(')', '')}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={`w-full ${className ?? ''}`}
>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
</linearGradient>
</defs>
{/* Area fill */}
<path d={areaPath} fill={`url(#${gradientId})`} />
{/* Stroke line */}
<path
d={pathCommands}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';
import { type VariantProps, cva } from 'class-variance-authority';
const tagVariants = cva(
'shadow-sm px-4 gap-2 center-center border self-auto text-xs rounded-full h-7',
{
variants: {
variant: {
light:
'bg-background-light dark:bg-background-dark text-muted-foreground',
dark: 'bg-foreground-light dark:bg-foreground-dark text-muted border-background/10 shadow-background/5',
},
},
defaultVariants: {
variant: 'light',
},
},
);
interface TagProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof tagVariants> {}
export function Tag({ children, className, variant, ...props }: TagProps) {
return (
<span className={cn(tagVariants({ variant, className }))} {...props}>
{children}
</span>
);
}

View File

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

View File

@@ -0,0 +1,88 @@
import {
BadgeIcon,
CheckCheckIcon,
CheckIcon,
HeartIcon,
MessageCircleIcon,
RefreshCwIcon,
} from 'lucide-react';
import Image from 'next/image';
interface TwitterCardProps {
avatarUrl?: string;
name: string;
handle: string;
content: React.ReactNode;
replies?: number;
retweets?: number;
likes?: number;
verified?: boolean;
}
export function TwitterCard({
avatarUrl,
name,
handle,
content,
replies = 0,
retweets = 0,
likes = 0,
verified = false,
}: TwitterCardProps) {
const renderContent = () => {
if (typeof content === 'string') {
return <p className="text-muted-foreground">{content}</p>;
}
if (Array.isArray(content) && typeof content[0] === 'string') {
return content.map((line) => <p key={line}>{line}</p>);
}
return content;
};
return (
<div className="border rounded-lg p-4 col gap-4 bg-background-light">
<div className="row gap-4">
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
{avatarUrl && (
<img src={avatarUrl} alt={name} width={48} height={48} />
)}
</div>
<div className="col gap-1">
<div className="col">
<div className="row gap-2 items-center">
<span className="font-medium">{name}</span>
{verified && (
<div className="relative">
<BadgeIcon className="size-4 fill-[#1D9BF0] text-[#1D9BF0]" />
<div className="absolute inset-0 center-center">
<CheckIcon className="size-2 text-white" strokeWidth={3} />
</div>
</div>
)}
</div>
<span className="text-muted-foreground text-sm leading-0">
@{handle}
</span>
</div>
{renderContent()}
<div className="row gap-4 text-muted-foreground text-sm mt-4">
<div className="row gap-2">
<MessageCircleIcon className="transition-all size-4 fill-background hover:fill-blue-500 hover:text-blue-500" />
{/* <span>{replies}</span> */}
</div>
<div className="row gap-2">
<RefreshCwIcon className="transition-all size-4 fill-background hover:text-emerald-500" />
{/* <span>{retweets}</span> */}
</div>
<div className="row gap-2">
<HeartIcon className="transition-all size-4 fill-background hover:fill-rose-500 hover:text-rose-500" />
{/* <span>{likes}</span> */}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item>>;
}) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Trigger>>;
}) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Content>>;
}) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
);
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,65 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'hover:-translate-y-[1px] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-[0_1px_0_0,0_-1px_0_0] shadow-foreground/10',
{
variants: {
variant: {
default: 'bg-foreground text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
naked:
'bg-transparent hover:bg-transparent ring-0 border-none !px-0 !py-0 shadow-none',
},
size: {
default: 'h-8 px-4',
sm: 'h-6 px-2',
lg: 'h-12 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = ({
ref,
className,
variant,
size,
asChild = false,
...props
}: ButtonProps & {
ref?: React.RefObject<HTMLButtonElement>;
}) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
};
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,46 @@
'use client';
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
const Slider = (
{
ref,
className,
tooltip,
...props
}
) => (<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-white/90" />
</SliderPrimitive.Track>
{tooltip ? (
<Tooltip open disableHoverableContent>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={10}
className="rounded-full bg-black text-white/70 py-1 text-xs border-white/30"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
)}
</SliderPrimitive.Root>);
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,34 @@
'use client';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof TooltipPrimitive.Content>>;
}) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,8 @@
const getMapJSON = require('dotted-map').getMapJSON;
// This function accepts the same arguments as DottedMap in the example above.
export const mapJsonString = getMapJSON({
height: 90,
grid: 'vertical',
avoidOuterPins: true,
});

View File

@@ -0,0 +1,138 @@
'use client';
import DottedMap from 'dotted-map/without-countries';
import { useEffect, useMemo, useState } from 'react';
import { mapJsonString } from './world-map-string';
// Static coordinates list with 50 points
const COORDINATES = [
// Western Hemisphere (Focused on West Coast)
{ lat: 47.6062, lng: -122.3321 }, // Seattle, USA
{ lat: 45.5155, lng: -122.6789 }, // Portland, USA
{ lat: 37.7749, lng: -122.4194 }, // San Francisco, USA
{ lat: 34.0522, lng: -118.2437 }, // Los Angeles, USA
{ lat: 32.7157, lng: -117.1611 }, // San Diego, USA
{ lat: 49.2827, lng: -123.1207 }, // Vancouver, Canada
{ lat: 58.3019, lng: -134.4197 }, // Juneau, Alaska
{ lat: 61.2181, lng: -149.9003 }, // Anchorage, Alaska
{ lat: 64.8378, lng: -147.7164 }, // Fairbanks, Alaska
{ lat: 71.2906, lng: -156.7886 }, // Utqiaġvik (Barrow), Alaska
{ lat: 60.5544, lng: -151.2583 }, // Kenai, Alaska
{ lat: 61.5815, lng: -149.444 }, // Wasilla, Alaska
{ lat: 66.1666, lng: -153.3707 }, // Bettles, Alaska
{ lat: 63.8659, lng: -145.637 }, // Delta Junction, Alaska
{ lat: 55.3422, lng: -131.6461 }, // Ketchikan, Alaska
// Eastern Hemisphere (Focused on East Asia)
{ lat: 35.6762, lng: 139.6503 }, // Tokyo, Japan
{ lat: 43.0621, lng: 141.3544 }, // Sapporo, Japan
{ lat: 26.2286, lng: 127.6809 }, // Naha, Japan
{ lat: 31.2304, lng: 121.4737 }, // Shanghai, China
{ lat: 22.3193, lng: 114.1694 }, // Hong Kong
{ lat: 37.5665, lng: 126.978 }, // Seoul, South Korea
{ lat: 25.033, lng: 121.5654 }, // Taipei, Taiwan
// Russian Far East
{ lat: 64.7336, lng: 177.5169 }, // Anadyr, Russia
{ lat: 59.5613, lng: 150.8086 }, // Magadan, Russia
{ lat: 43.1332, lng: 131.9113 }, // Vladivostok, Russia
{ lat: 53.0444, lng: 158.6478 }, // Petropavlovsk-Kamchatsky, Russia
{ lat: 62.0355, lng: 129.6755 }, // Yakutsk, Russia
{ lat: 48.4827, lng: 135.0846 }, // Khabarovsk, Russia
{ lat: 46.9589, lng: 142.7319 }, // Yuzhno-Sakhalinsk, Russia
{ lat: 52.9651, lng: 158.2728 }, // Yelizovo, Russia
{ lat: 56.1304, lng: 101.614 }, // Bratsk, Russia
// Australia & New Zealand (Main Cities)
{ lat: -33.8688, lng: 151.2093 }, // Sydney, Australia
{ lat: -37.8136, lng: 144.9631 }, // Melbourne, Australia
{ lat: -27.4698, lng: 153.0251 }, // Brisbane, Australia
{ lat: -31.9505, lng: 115.8605 }, // Perth, Australia
{ lat: -12.4634, lng: 130.8456 }, // Darwin, Australia
{ lat: -34.9285, lng: 138.6007 }, // Adelaide, Australia
{ lat: -42.8821, lng: 147.3272 }, // Hobart, Australia
{ lat: -16.9186, lng: 145.7781 }, // Cairns, Australia
{ lat: -23.7041, lng: 133.8814 }, // Alice Springs, Australia
{ lat: -41.2865, lng: 174.7762 }, // Wellington, New Zealand
{ lat: -36.8485, lng: 174.7633 }, // Auckland, New Zealand
{ lat: -43.532, lng: 172.6306 }, // Christchurch, New Zealand
];
export function WorldMap() {
const [visiblePins, setVisiblePins] = useState<typeof COORDINATES>([]);
const activePinColor = '#2265EC';
const inactivePinColor = '#818181';
const visiblePinsCount = 20;
// Helper function to get random coordinates
const getRandomCoordinates = (count: number) => {
const shuffled = [...COORDINATES].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
// Helper function to update pins
const updatePins = () => {
setVisiblePins((current) => {
const newPins = [...current];
// Remove 2 random pins
const pinsToAdd = 4;
if (newPins.length >= pinsToAdd) {
for (let i = 0; i < pinsToAdd; i++) {
const randomIndex = Math.floor(Math.random() * newPins.length);
newPins.splice(randomIndex, 1);
}
}
// Add 2 new random pins from the main coordinates
const availablePins = COORDINATES.filter(
(coord) =>
!newPins.some(
(pin) => pin.lat === coord.lat && pin.lng === coord.lng,
),
);
const newRandomPins = availablePins
.sort(() => 0.5 - Math.random())
.slice(0, pinsToAdd);
return [...newPins, ...newRandomPins].slice(0, visiblePinsCount);
});
};
useEffect(() => {
// Initial pins
setVisiblePins(getRandomCoordinates(10));
// Update pins every 4 seconds
const interval = setInterval(updatePins, 2000);
return () => clearInterval(interval);
}, []);
const map = useMemo(() => {
const map = new DottedMap({ map: JSON.parse(mapJsonString) });
visiblePins.forEach((coord) => {
map.addPin({
lat: coord.lat,
lng: coord.lng,
svgOptions: { color: activePinColor, radius: 0.3 },
});
});
return map.getSVG({
radius: 0.2,
color: inactivePinColor,
shape: 'circle',
});
}, [visiblePins]);
return (
<div>
<img
loading="lazy"
alt="World map with active users"
src={`data:image/svg+xml;utf8,${encodeURIComponent(map)}`}
className="object-contain w-full h-full"
width={1200}
height={630}
/>
</div>
);
}

View File

@@ -0,0 +1,51 @@
- **Language**
- Use American English.
- Be concise and dont hallucinate.
- Dont use emojis.
- Dont use hashtags.
- Dont use to fancy english. Keep it simple and readable.
- **Title Guidelines**
- Ensure titles are engaging and informative.
- Checks:
- **Length**: Titles should be concise, ideally under 70 characters.
- **Format**: Titles should start with a capital letter.
- **Introduction**
- Write a compelling introduction that summarizes the article.
- Checks:
- **Length**: Introduction should be at least 50 words.
- **Content Structure**
- Use headings and subheadings to organize content.
- Do not use to many headings either. Aiming for 3-5 depending on structure and article length.
- Avoid doing to many bullet points with the format `- **Bold text** Lorem ipsum dolor sit amet`. (max 1 per article)
- Checks:
- **Presence**: Use at least one H2 heading to structure the article.
- **Paragraph Length**
- Keep paragraphs short and readable.
- Checks:
- **Length**: Paragraphs should not exceed 150 words.
- **Use of Images**
- Incorporate images to enhance the article.
- Checks:
- **Presence**: Include at least one image to support the content.
- **SEO Best Practices**
- Incorporate keywords naturally throughout the article.
- Look in this folder for keywords in other mdx files.
- Checks:
- **Keyword Density**: Maintain keyword density between 0.5% and 2.5%.
- **Conclusion**
- Summarize the main points and provide a call to action.
- Checks:
- **Length**: Conclusion should be at least 30 words.
- **Grammar and Spelling**
- Ensure correct grammar and spelling throughout the article.
- Checks:
- **Spell Check**: Check for spelling errors.
- **Grammar Check**: Check for grammatical errors.

View File

@@ -0,0 +1,180 @@
---
title: Find an alternative to Mixpanel
description: A list of alternatives to Mixpanel, including open source and paid options.
date: 2024-11-12
team: OpenPanel Team
tag: Comparison
cover: /content/cover-alternatives.jpg
---
> Want to understand how people use your website? You might think of using Mixpanel first. But it can be complex and hard to learn.
Think about using something else that's just as good but simpler to use. A tool that makes collecting data easy without the struggle of learning complex features.
Here's what a better website analytics tool can give you:
- **Confidence**: Make choices based on data
- **Efficiency**: Work faster with your analytics
- **Ease**: Less complex, easier to learn
## Understanding Website Analytics
Website analytics helps you collect and understand website data. It shows you how people use your website.
Since 2016, more companies have started using digital analytics. Companies now want to know how users behave, spot patterns, and make better choices using data.
Just counting website visits isn't enough anymore. Understanding how users interact with your website can show you important insights and opportunities.
Good website analytics helps you set goals, track progress, and make your website better. You need tools that are both powerful and easy to use.
These tools turn raw numbers into useful insights, helping you stay ahead of others.
## Introduction to Mixpanel
Mixpanel is a powerful analytics tool that helps marketers, developers, and product managers understand how users behave on their websites and apps.
Started in 2009, this platform changed how we look at data in real-time.
Mixpanel helps teams track user engagement and keep users coming back.
It tracks specific user actions instead of just page views, giving you better insights into what users do.
Its dashboard shows real-time data clearly, helping teams make better decisions.
Mixpanel remains a strong player in analytics, helping businesses improve their online presence.
## Limitations of Mixpanel
Despite its strengths, Mixpanel has several problems users need to deal with.
First, Mixpanel's pricing is often too high. The cost of all its features may not make sense for smaller companies or startups, making it hard for growing businesses to use. Simply put, you might not get enough value for what you pay.
Second, Mixpanel is hard to learn. New users often struggle with its complex interface.
Third, Mixpanel doesn't work well with some important business tools. This makes it hard to connect all your data in one place.
Lastly, setting up event tracking is difficult. Users need to carefully set up tracking for each action they want to monitor, which takes time and can lead to mistakes. This means teams often spend too much time setting things up instead of using the data right away.
## The Need for Simpler Solutions
In today's busy market, having easy-to-use analytics is key for business success.
Simple tools help companies collect and understand data without confusion.
These tools are easy to use and quick to set up, saving time and money. By making data collection and analysis simpler, businesses of any size can use analytics without needing technical experts or long training.
More importantly, simple solutions help small and medium-sized businesses compete better. Good data insights can change how well a business does. With easy-to-use alternatives to Mixpanel, even businesses with small budgets can grow and make smart choices. Using these simple tools lets businesses focus on what matters—growing and succeeding.
## Key Features to Look For
When choosing a Mixpanel alternative, look for these important features:
- Easy to set up
- Real-time data
- Simple to use
- Good data charts and graphs
First, it should be easy to connect with your website.
The tool should show you data as it happens, helping you make quick decisions.
It should be easy to use. A tool that's simple to understand saves time and is easier to learn.
Good data charts are important. Look for tools that show data in ways that make sense to you.
You should be able to change the tool to fit your needs.
Lastly, it should be worth the money. The best tool gives you good features at a fair price.
## Benefits of a Mixpanel Alternative
Using a simpler alternative to Mixpanel can make your work easier and better.
First, it's more efficient. Simpler tools are faster to learn and use, helping your team work better. When tools are easier to use, people enjoy using them more.
Also, you can save money. Many alternatives do similar things as Mixpanel but cost less, letting you spend money on other important things while still getting good analytics.
Finally, alternatives often let you customize more things to fit your needs. This helps you get better insights and make better plans.
## Cost-Effectiveness
> Choosing a simpler Mixpanel alternative can save you money and help you grow.
The lower prices of many alternatives help you save money. You can use this saved money for other important things while still getting good analytics. These savings add up over time.
**Key Benefits:**
- Lower prices
- Fewer extra features you don't need
- Less training needed
- Faster to start using
In the end, saving money with alternatives isn't just about the price. By using simpler tools, businesses can balance cost and features better.
## User-Friendly Interface
> A simple, easy-to-use design means you can start using the data quickly, without lots of training.
A big benefit of Mixpanel alternatives is how easy they are to use. This helps everyone use the tool well, no matter their tech skills.
Simple navigation helps you work faster. Users can find what they need quickly and easily. By focusing on the main features, users don't get confused by too many options.
**Impact:** When tools are easy to use, teams can do better work without getting frustrated. Clear dashboards show important information simply.
## Customizable Reports
> Your data, your way. Turn numbers into insights that help you take action.
Custom reports let you see data how you want to. This saves time and helps you understand complex data better.
A good Mixpanel alternative should have:
- Easy drag-and-drop tools
- Live data updates
- Different ways to show charts
## Data Accuracy
> Accurate data helps you trust your analytics.
In website analytics, good data is very important. It affects your decisions and results. With a Mixpanel alternative, getting accurate data is key.
**Important Points:**
- Data you can trust
- Regular accuracy checks
- Ongoing data testing
- Building trust with your team
## Real-Time Analytics
> In today's fast-moving online world, seeing data right away helps you make better decisions.
These tools let you watch how people use your website as it happens. You don't have to wait for reports; you see everything right away.
**Impact:** Whether you're tracking clicks, page views, or sales, seeing data right away helps you fix problems quickly and find new opportunities.
## Tips for Transitioning
> A good switch starts with a clear plan and ends with confident users.
Start by making a clear plan with goals and timelines. This helps everyone understand what's happening.
**Best Steps:**
1. Get your team involved early
2. Train everyone well
3. Test with a small project first
4. Keep talking with your team
5. Find team members who can help others
## Future Trends in Website Analytics
> As websites change, analytics tools are changing too, bringing new ways to understand data.
**Important New Trends:**
**AI tools**
AI helps businesses not just understand what users did before, but guess what they might do next.
**Privacy first analytics**
With new privacy laws like GDPR and CCPA, companies are finding new ways to get insights while protecting user privacy.
**Quick data updates**
Getting data quickly helps businesses make faster, better decisions.
By using these new tools, businesses can better understand their users and do better online.

View File

@@ -0,0 +1,43 @@
---
title: Introduction to OpenPanel
description: OpenPanel is a versatile analytics platform that offers a wide array of features to meet your data analysis needs.
tag: Introduction
team: OpenPanel Team
date: 2024-11-09
---
Welcome to OpenPanel, the open-source analytics platform designed to be a robust alternative to Mixpanel and a great substitute for Google Analytics. In this article, we'll explore why OpenPanel is the ideal choice for businesses looking to leverage powerful analytics while maintaining control over their data.
## Why Open Source?
At OpenPanel, we are committed to the principles of open-source software. By making our code publicly available, we invite a community of developers and users to contribute to and enhance our platform. This collaborative approach not only fosters innovation but also ensures transparency in how data is managed and processed—a crucial consideration in today's data-driven world. You can explore our code and contribute on [GitHub](https://github.com/openpanel/openpanel).
## Why Choose OpenPanel?
Our journey began with a vision to create an open-source alternative to Mixpanel, a tool we admired for its product analytics capabilities. However, as we developed OpenPanel, we realized the potential to offer more comprehensive features that Mixpanel lacked, particularly in the realm of web analytics. While Mixpanel excels in product analytics, it doesn't fully address web analytics needs. OpenPanel bridges this gap by integrating both web and product analytics, providing a holistic view of user behavior.
## What Can You Do with OpenPanel?
OpenPanel is a versatile analytics platform that offers a wide array of features to meet your data analysis needs:
- **Web Analytics**: Gain insights similar to tools like Plausible, Fathom, and Simple Analytics.
- **Product Analytics**: Analyze product usage and user interactions, akin to Mixpanel.
- **User Retention**: Track and enhance user retention rates.
- **Funnels**: Visualize user journeys and conversion paths.
- **Events**: Monitor specific user actions and interactions.
- **Profiles**: Create detailed user profiles to better understand your audience.
- **Real-Time View**: Display real-time data on a big monitor in your office for dynamic insights.
- **Export API**: Seamlessly export your data for further analysis.
- **Chart API**: Integrate custom visualizations into your dashboards.
## Commitment to Privacy
Privacy and data protection are at the core of OpenPanel's philosophy. We believe that your data is your property, and you should have full control over it. Our tracking script is fully open-source and complies with GDPR and CCPA regulations. Unlike many analytics tools, we do not use cookies to track users; instead, we utilize fingerprinting techniques similar to Plausible, ensuring user privacy without sacrificing functionality.
## Your Data is Safe with Us
At OpenPanel, your data security is our priority. We never sell your data to third parties. To sustain our service, we charge a small fee, but our pricing remains competitive with other analytics solutions. If you prefer, you can also self-host OpenPanel, maintaining complete control over your data. You can delete or export your data at any time, ensuring no vendor lock-in.
## Listening to Feedback
Our users are our greatest asset, and their feedback shapes the evolution of OpenPanel. We actively seek input from our community to refine existing features and introduce new ones that support business growth. Your suggestions drive our innovation, helping us deliver a product that meets your needs.

View File

@@ -0,0 +1,106 @@
---
title: Top 7 Open-Source Web Analytics Tools
description: In an era where data drives decisions, what are your best options for web analytics?
date: 2024-11-10
cover: /content/cover-best-web-analytics.jpg
tag: Comparison
team: OpenPanel Team
---
In an era where data drives decisions, what are your best options for web analytics?
Consider the power and potential of open-source alternatives to proprietary solutions. Discovering these tools can significantly elevate your insights while maintaining flexibility and control.
## 1. Understanding Web Analytics Challenges
Navigating the landscape of web analytics presents numerous challenges that require careful deliberation and strategic management.
Firstly, creating a comprehensive data strategy is a daunting task that requires a clear understanding of key performance indicators (KPIs) and user behavior metrics. This complexity is further compounded by the necessity of integrating data from various sources, creating a multifaceted view of user interactions.
Moreover, data accuracy is a perpetual concern in web analytics. Ensuring that collected data is both accurate and relevant requires robust validation methods, alongside consistency checks to prevent discrepancies that could distort analytics insights.
Finally, the challenge of real-time data analysis looms large for many organizations. To truly harness the power of their analytics, enterprises must adopt solutions that provide immediate, actionable insights. This necessitates not only advanced technical infrastructure but also a skilled team capable of interpreting and reacting to data in real-time, driving agile decision-making.
## 2. Plausible - A Privacy-Focused Solution
Plausible emerges as a robust alternative, offering a vital blend of simplicity, transparency, and privacy in web analytics. This solution stands out because it respects user privacy while delivering meaningful insights. Plausible is designed to align seamlessly with the modern emphasis on data protection.
Plausibles distinguishing feature lies in its commitment to not collecting personal data. Consequently, this principled stance minimizes the risk of privacy breaches. Users can enjoy peace of mind knowing their information is handled with care.
Moreover, Plausibles interface is intuitively crafted to ensure ease of use while maintaining comprehensive functionality. Its particularly suitable for users who desire straightforward yet powerful analytics solutions.
Serving as a beacon for ethical web analytics, Plausible avoids employing cookies and complies with privacy laws such as GDPR, CCPA, and PECR. This implementation instills trust and reliability among businesses and their users.
Plausibles affordability and clear, concise data presentation make it an attractive option for startups and enterprises alike, interested in extracting maximum value from their web analytics. Furthermore, it remains open-source, welcoming community contributions that continually enhance its features.
In essence, Plausible excels by marrying simplicity with ethical data practices. The focus on privacy does not compromise the depth of insights provided. This makes Plausible an inspiring choice for forward-thinking businesses.
## 3. Matomo - Comprehensive Data Control
Matomo epitomizes robust data control.
Formerly known as Piwik, Matomo provides exhaustive data ownership. This open-source web analytics platform offers a powerful alternative to proprietary tools, giving you the ultimate autonomy over your user data. Consequently, you can bypass the usual data governance concerns associated with third-party services.
Data privacy is Matomo's utmost priority.
Its infrastructure guarantees that your sensitive data is stored on your servers, ensuring compliance with rigorous privacy standards. This autonomous setup fosters trust, providing stakeholders with the reassurance of uncompromised data security.
Beyond data control, Matomo supports a suite of advanced analytics features. These capabilities include customizable dashboards, granular user segmentation, and detailed visitor profiles. As a result, businesses can extract deep insights while adhering to their unique data governance policies.
Matomos future-proof design and open-source nature position it as an enduring solution in web analytics. Through an engaged community and continuous updates, Matomo remains adaptive to the evolving digital landscape, ensuring its users stay ahead of their analytical needs.
## 4. Fathom - User-Friendly with Great Privacy
Fathom is distinguished by its exceptional ease of use and robust privacy features. Its designed to simplify analytics for everyone, from novices to experts.
Fathom guarantees that user data remains confidential.
Users can attain actionable insights without compromising privacy, paving the way for a balance between data-driven decision-making and stringent privacy standards. Emphasizing simplicity, Fathom provides intuitive interfaces and dashboards that enable swift comprehension and application.
Beyond ease of use, Fathoms minimalist approach significantly reduces the learning curve, making it accessible even to those new to web analytics. In doing so, it empowers businesses to harness critical insights rapidly, ensuring that privacy concerns never hinder analytical capabilities. Moreover, Fathom's commitment to “zero” tracking ensures peace of mind while leveraging insightful data.
## 5. Umami - Simple and Effective Analytics
Umami is an open-source web analytics tool that stands out for its simplicity, usability, and potent capabilities. This platform appeals to those who prioritize straightforward yet comprehensive analysis of their web traffic.
With Umami, you enjoy a clutter-free interface.
Contrary to more complex systems, Umami minimizes the learning curve.
Umami assesses data efficiently, offering insights in an instantly understandable format.
The platform fosters data-driven decision-making without unnecessary complexity, presenting neatly organized statistics and metrics. Furthermore, it prides itself on user privacy and does not collect IP addresses.
Ultimately, Umami is testament to how simplicity and effectiveness can coexist, securing its place as a distinguished choice in web analytics. This platform allows you to focus on actionable insights without wading through extraneous data.
## 6. PostHog - Powerful and Self-Hosted Insights
PostHog truly shines as a robust, open-source solution for insightful web analytics.
Offering extensive, self-hosted analytics, it provides businesses with unrivaled control and privacy. This setup gives firms the leverage to harness detailed data about user behavior while maintaining stringent data security protocols. You can track all sorts of interactions, from clicks to conversions, with exquisite precision.
Notably, PostHog thrives on contributing towards community-driven development. It continually evolves with feedback from its users, ensuring that the tool stays up-to-date with the latest web analytics trends and needs. Therefore, you are not just adopting a tool; you are joining a vibrant and proactive community.
Embrace the power of PostHog for a data-driven future. Its functionality goes beyond basic metrics, offering intricate insights and real-time features. This gives you an edge, enabling strategic decision-making based on comprehensive and reliable data garnered in real-time.
## 7. Ackee - Minimalistic and Self-Hosted
When you envision simplicity, efficiency, and security in web analytics, Ackee stands out brilliantly.
Founded in 2016, Ackee exemplifies a minimalistic approach with no compromise on essential functionalities. Designed to be self-hosted, it ensures that sensitive data resides exclusively on your servers, giving you complete control.
Even more impressive is how Ackees straightforward user interface makes it exceedingly easy to integrate and use. With support for various platforms, you can accurately track visitor patterns and engagement across multiple digital touchpoints without any hassle.
Its lightweight nature means Ackee wont burden your system resources, allowing for both efficiency and speed. Installation is simple, typically completed within 3 minutes, offering extensive customization options and highly intuitive dashboards.
Ackees compelling advantage lies in its focus on privacy and data security. It provides web analytics without compromising the trust of your users.
## OpenPanel - In our opinion the best option
When searching for a comprehensive, open-source web analytics tool, OpenPanel stands out remarkably, offering an unparalleled suite of features.
Its blend of ease-of-use and robust functionality is simply exceptional.
OpenPanel not only delivers detailed analytics but also integrates seamlessly with our existing tech stack. This ensures a consistent user experience, fostering both simplicity and productivity.
The software's intuitive analytics allow for deep insights into user behavior, making it a perfect solution for dynamic and data-driven organizations. Its high degree of customization ensures it adapts to our specific requirements, embodying the perfect balance of flexibility and reliability. Thus, OpenPanel positions itself as the premier choice for innovators seeking an open-source alternative in web analytics.

View File

@@ -0,0 +1,103 @@
---
title: Mixpanel vs OpenPanel
description: A comparison between Mixpanel and OpenPanel
date: 2024-11-13
tag: Comparison
team: OpenPanel Team
cover: /content/cover-mixpanel.jpg
---
import { Figure } from "@/components/figure";
OpenPanel is based on the same principles as Mixpanel, but with a few key differences. We'll go through some of the features and see how they compare.
## Web analytics
Mixpanel is a great product analytics tool but in our minds its lacking in this area. Web analytics should always be easy to get going and we think Mixpanel has to much focus on product analytics.
In OpenPanel you do not need to do anything to get your web analytics up and running. Just add the tracking snippet to your website or app and you're up and running.
<Figure
src="/content/screenshot-web-analytics.png"
alt="OpenPanel web analytics dashboard showing pageviews, sessions and other key metrics"
caption="OpenPanel's web analytics dashboard provides key metrics at a glance"
/>
## Product analytics
Mixpanel's strength is in product analytics and it's hard to beat (to be honest). Nevertheless we aim to have the same great features in OpenPanel.
Probably the most used feature in Mixpanel is their report tool, where you can create all kinds of charts and see how different things are doing. We have tried to make a similar experiance where you can pick and choose different metrics and dimensions to create your own custom reports.
Some of the features we have added are:
- **Funnels**
- **Retention**
- **Line charts**
- **Bar charts**
- **Histogram charts**
- **Area charts**
- **Pie charts**
- **Map charts**
- **Events**
- **Profiles**
<Figure
src="/content/screenshot-report-funnel.png"
alt="OpenPanel report tool showing a funnel"
caption="OpenPanel's report tool provides a wide range of charts and metrics"
/>
## Cookies vs Cookieless
Mixpanel is a cookie-based tool, which means that it relies on cookies to track users. This provides advantages like:
- More accurate user identification across sessions
- Better cross-domain tracking
- Easier integration with existing cookie-based systems
However, it also comes with challenges:
- Requires cookie consent banners in many jurisdictions
- Can be blocked by ad blockers and privacy-focused browsers
- May not work with upcoming cookie restrictions
OpenPanel uses a cookieless approach, relying instead on privacy-preserving techniques like fingerprinting and session-based tracking. This offers benefits such as:
- No cookie consent banners required
- Works even when cookies are blocked
- Future-proof against upcoming cookie restrictions
> Its up to you to decide what's best for your users and your business.
## Realtime
Both Mixpanel and OpenPanel have real-time analytics. Its just a matter of seconds before you can see what's happening in your product or website.
But we have added a new feature in OpenPanel which we call `Realtime`. It's similar to Google Analytics' real-time view since we love looking at big screens with live data.
<Figure
src="/content/screenshot-realtime.png"
alt="OpenPanel real-time analytics dashboard showing active users and other key metrics"
caption="See where all your users are at the moment in OpenPanel's realtime view"
/>
## Notifications
In OpenPanel you can create notifications for different events. This is a great way to stay on top of things and get notified when something is happening.
You can define advanced conditions when and what to notify you about. We have several integrations with other tools so you can easily connect your notifications to other tools you use.
As of now, we don't believe Mixpanel has this feature.
## Similarities
### Profiles
Both Mixpanel and OpenPanel allow you to see profiles of your users. This is a great way to understand your users and see how they are doing.
### Events
You get new events in realtime in both Mixpanel and OpenPanel, you can search and filter on any property. Mixpanel might be a bit faster but it's not a big difference.
## Conclusion
Mixpanel is a great product analytics tool but in our minds its lacking in this area. Web analytics should always be easy to get going and we think Mixpanel has to much focus on product analytics.
**OpenPanel is a great alternative to Mixpanel** if you want to get started with analytics quickly and easily.

View File

@@ -1,6 +1,7 @@
# Export API
The Export API allows you to retrieve event data and chart data from your OpenPanel projects.
---
title: Export
description: The Export API allows you to retrieve event data and chart data from your OpenPanel projects.
---
## Authentication

View File

@@ -0,0 +1,4 @@
{
"title": "API",
"pages": ["track", "export"]
}

View File

@@ -1,6 +1,7 @@
# OpenPanel REST API with cURL
This guide demonstrates how to interact with the OpenPanel API using cURL. These examples provide a low-level understanding of the API endpoints and can be useful for testing or for integrations where a full SDK isn't available.
---
title: Track
description: This guide demonstrates how to interact with the OpenPanel API using cURL. These examples provide a low-level understanding of the API endpoints and can be useful for testing or for integrations where a full SDK isn't available.
---
## Good to know

View File

@@ -0,0 +1,59 @@
---
title: Introduction
description: The OpenPanel SDKs provide a set of core methods that allow you to track events, identify users, and more. Here's an overview of the key methods available in the SDKs.
---
<Callout>
While all OpenPanel SDKs share a common set of core methods, some may have
syntax variations or additional methods specific to their environment. This
documentation provides an overview of the base methods and available SDKs.
</Callout>
## Core Methods
### Set global properties
Sets global properties that will be included with every subsequent event.
### Track
Tracks a custom event with the given name and optional properties.
#### Tips
You can identify the user directly with this method.
```js filename="Example shown in JavaScript"
track('your_event_name', {
foo: 'bar',
baz: 'qux',
// reserved property name
__identify: {
profileId: 'your_user_id', // required
email: 'your_user_email',
firstName: 'your_user_name',
lastName: 'your_user_name',
avatar: 'your_user_avatar',
}
});
```
### Identify
Associates the current user with a unique identifier and optional traits.
### Alias
Creates an alias for a user identifier.
### Increment
Increments a numeric property for a user.
### Decrement
Decrements a numeric property for a user.
### Clear
Clears the current user identifier and ends the session.

View File

@@ -1,6 +1,7 @@
# Migrate from `beta` to `v1`
We are happy to announce the release of `v1` of the Openpanel SDK. This release includes a lot of improvements and changes to the SDK. This guide will help you migrate from the `beta` version to the `v1` version.
---
title: Beta to V1
description: We are happy to announce the release of `v1` of the Openpanel SDK. This release includes a lot of improvements and changes to the SDK. This guide will help you migrate from the `beta` version to the `v1` version.
---
## General

View File

@@ -0,0 +1,5 @@
---
title: Astro
---
You can use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) to track events in Astro.

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