feature(public,docs): new public website and docs
39
apps/public/.gitignore
vendored
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
96
apps/public/app/(content)/[...pages]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
apps/public/app/(content)/articles/[articleSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/public/app/(content)/articles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/public/app/(content)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
apps/public/app/api/search/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
46
apps/public/app/docs/[[...slug]]/page.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
12
apps/public/app/docs/layout.tsx
Normal 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
@@ -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;
|
||||
}
|
||||
55
apps/public/app/layout.config.tsx
Normal 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)!;
|
||||
};
|
||||
72
apps/public/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
28
apps/public/app/not-found.tsx
Normal 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't find what you were looking for.
|
||||
</p>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/public/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/public/app/sitemap.ts
Normal 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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
152
apps/public/components/Swirls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/public/components/common-sdk-config.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
##### Common options
|
||||
|
||||
- `apiUrl` - The url of the openpanel API or your self-hosted instance
|
||||
- `clientId` - The client id of your application
|
||||
- `clientSecret` - The client secret of your application (**only required for server-side events**)
|
||||
- `filter` - A function that will be called before sending an event. If it returns false, the event will not be sent
|
||||
- `disabled` - If true, the library will not send any events
|
||||
- `waitForProfile` - If true, the library will wait for the profile to be set before sending events
|
||||
12
apps/public/components/device-id-warning.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function DeviceIdWarning() {
|
||||
return (
|
||||
<Callout>
|
||||
Read more about{' '}
|
||||
<Link href="/docs/device-id">device id and why you might want it</Link>.
|
||||
**We recommend not to but it's up to you.**
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
125
apps/public/components/feature.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/public/components/figure.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/public/components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/public/components/github-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
apps/public/components/hero-carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/public/components/hero-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/public/components/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/public/components/line.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
apps/public/components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/public/components/navbar.tsx
Normal 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;
|
||||
10
apps/public/components/personal-data-warning.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
export function PersonalDataWarning() {
|
||||
return (
|
||||
<Callout>
|
||||
Keep in mind that this is considered personal data. Make sure you have the
|
||||
users consent before calling this!
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
59
apps/public/components/pricing-slider.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/public/components/section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
apps/public/components/sections/faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
apps/public/components/sections/features.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
apps/public/components/sections/features/events-feature.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
131
apps/public/components/sections/features/profiles-feature.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
77
apps/public/components/sections/pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/public/components/sections/sdks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
apps/public/components/sections/stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
apps/public/components/sections/testimonials.tsx
Normal 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 it’s 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>
|
||||
);
|
||||
}
|
||||
70
apps/public/components/simple-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/public/components/tag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/public/components/toc.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
apps/public/components/twitter-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/public/components/ui/accordion.tsx
Normal 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 };
|
||||
65
apps/public/components/ui/button.tsx
Normal 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 };
|
||||
46
apps/public/components/ui/slider.tsx
Normal 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 };
|
||||
34
apps/public/components/ui/tooltip.tsx
Normal 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 };
|
||||
6
apps/public/components/web-sdk-config.mdx
Normal file
@@ -0,0 +1,6 @@
|
||||
##### Web options
|
||||
|
||||
- `trackScreenViews` - If true, the library will automatically track screen views (default: false)
|
||||
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
|
||||
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)
|
||||
|
||||
8
apps/public/components/world-map-string.ts
Normal 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,
|
||||
});
|
||||
138
apps/public/components/world-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/public/content/articles/.cursorrules
Normal 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.
|
||||
180
apps/public/content/articles/alternatives-to-mixpanel.mdx
Normal 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.
|
||||
43
apps/public/content/articles/introduction-to-openpanel.mdx
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
Plausible’s 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, Plausible’s interface is intuitively crafted to ensure ease of use while maintaining comprehensive functionality. It’s 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.
|
||||
|
||||
Plausible’s 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.
|
||||
|
||||
Matomo’s 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. It’s 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, Fathom’s 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 Ackee’s 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 won’t 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.
|
||||
|
||||
Ackee’s 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.
|
||||
103
apps/public/content/articles/vs-mixpanel.mdx
Normal 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.
|
||||
177
apps/public/content/docs/api/export.mdx
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
title: Export
|
||||
description: The Export API allows you to retrieve event data and chart data from your OpenPanel projects.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the Export API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Export API.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel client secret
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/export/events' \
|
||||
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Get events from a specific project within a date range.
|
||||
|
||||
Endpoint: `GET /export/events`
|
||||
|
||||
Parameters:
|
||||
- project_id (required): The ID of the project
|
||||
- event (optional): Filter by event name(s). Can be a single event or an array of events.
|
||||
- start (optional): Start date (format: YYYY-MM-DD)
|
||||
- end (optional): End date (format: YYYY-MM-DD)
|
||||
- page (optional, default: 1): Page number for pagination
|
||||
- limit (optional, default: 50, max: 50): Number of events per page
|
||||
- includes (optional): Additional fields to include in the response
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/export/events?project_id=abc&event=screen_view&start=2024-04-15&end=2024-04-18' \
|
||||
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description | Example |
|
||||
|-----------|------|-------------|---------|
|
||||
| projectId | string | The ID of the project to fetch events from | `abc123` |
|
||||
| event | string or string[] | Event name(s) to filter | `screen_view` or `["screen_view","button_click"]` |
|
||||
| start | string | Start date for the event range (ISO format) | `2024-04-15` |
|
||||
| end | string | End date for the event range (ISO format) | `2024-04-18` |
|
||||
| page | number | Page number for pagination (default: 1) | `2` |
|
||||
| limit | number | Number of events per page (default: 50, max: 50) | `25` |
|
||||
| includes | string or string[] | Additional fields to include in the response | `profile` or `["profile","meta"]` |
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/export/events?project_id=abc123&event=screen_view&start=2024-04-15&end=2024-04-18&page=1&limit=50&includes=profile,meta' \
|
||||
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"count": number,
|
||||
"totalCount": number,
|
||||
"pages": number,
|
||||
"current": number
|
||||
},
|
||||
"data": Array<Event>
|
||||
}
|
||||
```
|
||||
|
||||
## Charts
|
||||
|
||||
Retrieve chart data for a specific project.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET /export/charts
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description | Example |
|
||||
|-----------|------|-------------|---------|
|
||||
| projectId | string | The ID of the project to fetch chart data from | `abc123` |
|
||||
| events | string[] | Array of event names to include in the chart | `["sign_up","purchase"]` |
|
||||
| breakdowns | object[] | Array of breakdown configurations | `[{"name":"country"}]` |
|
||||
| interval | string | Time interval for data points | `day` |
|
||||
| range | string | Predefined date range | `last_7_days` |
|
||||
| previous | boolean | Include data from the previous period | `true` |
|
||||
| startDate | string | Custom start date (ISO format) | `2024-04-01` |
|
||||
| endDate | string | Custom end date (ISO format) | `2024-04-30` |
|
||||
| chartType | string | Type of chart to generate | `linear` |
|
||||
| metric | string | Metric to use for calculations | `sum` |
|
||||
| limit | number | Limit the number of results | `10` |
|
||||
| offset | number | Offset for pagination | `0` |
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&events=["sign_up","purchase"]&interval=day&range=last_30_days&chartType=linear&metric=sum' \
|
||||
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The response will include chart data with series, metrics, and optional previous period comparisons based on the input parameters.
|
||||
|
||||
## Funnel
|
||||
|
||||
Retrieve funnel data for a specific project.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET /export/funnel
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description | Example |
|
||||
|-----------|------|-------------|---------|
|
||||
| projectId | string | The ID of the project to fetch funnel data from | `abc123` |
|
||||
| events | object[] | Array of event configurations for the funnel steps | `[{"name":"sign_up","filters":[]}]` |
|
||||
| range | string | Predefined date range | `last_30_days` |
|
||||
| startDate | string | Custom start date (ISO format) | `2024-04-01` |
|
||||
| endDate | string | Custom end date (ISO format) | `2024-04-30` |
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/export/funnel?projectId=abc123&events=[{"name":"sign_up"},{"name":"purchase"}]&range=last_30_days' \
|
||||
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The response will include funnel data with total sessions and step-by-step breakdown of the funnel progression.
|
||||
|
||||
```json
|
||||
{
|
||||
"totalSessions": number,
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"name": string,
|
||||
"displayName": string
|
||||
},
|
||||
"count": number,
|
||||
"percent": number,
|
||||
"dropoffCount": number,
|
||||
"dropoffPercent": number,
|
||||
"previousCount": number
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All date parameters should be in ISO format (YYYY-MM-DD).
|
||||
- The `range` parameter accepts values like `today`, `yesterday`, `last_7_days`, `last_30_days`, `this_month`, `last_month`, `this_year`, `last_year`, `all_time`.
|
||||
- The `interval` parameter accepts values like `minute`, `hour`, `day`, `month`.
|
||||
- The `chartType` parameter can be `linear` or other supported chart types.
|
||||
- The `metric` parameter can be `sum`, `average`, `min`, or `max`.
|
||||
|
||||
Remember to replace `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` with your actual OpenPanel API credentials.
|
||||
4
apps/public/content/docs/api/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"pages": ["track", "export"]
|
||||
}
|
||||
145
apps/public/content/docs/api/track.mdx
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
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
|
||||
|
||||
- If you want to track **geo location** you'll need to pass the `ip` property as a header `x-client-ip`
|
||||
- If you want to track **device information** you'll need to pass the `user-agent` property as a header `user-agent`
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests to the OpenPanel API require authentication. You'll need to include your `clientId` and `clientSecret` in the headers of each request.
|
||||
|
||||
```bash
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Base URL
|
||||
|
||||
All API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev
|
||||
```
|
||||
|
||||
### Tracking Events
|
||||
|
||||
To track an event:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "track",
|
||||
"payload": {
|
||||
"name": "my_event",
|
||||
"properties": {
|
||||
"foo": "bar"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "identify",
|
||||
"payload": {
|
||||
"profileId": "123",
|
||||
"firstName": "Joe",
|
||||
"lastName": "Doe",
|
||||
"email": "joe@doe.com",
|
||||
"properties": {
|
||||
"tier": "premium"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Creating Aliases
|
||||
To create an alias for a user:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "alias",
|
||||
"payload": {
|
||||
"profileId": "1",
|
||||
"alias": "a1"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
To increment a numeric property:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "increment",
|
||||
"payload": {
|
||||
"profileId": "1",
|
||||
"property": "visits",
|
||||
"value": 1
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
To decrement a numeric property:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "decrement",
|
||||
"payload": {
|
||||
"profileId": "1",
|
||||
"property": "visits",
|
||||
"value": 1
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error.
|
||||
Example error response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Invalid client credentials",
|
||||
"status": 401
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
The API implements rate limiting to prevent abuse. If you exceed the rate limit, you'll receive a 429 (Too Many Requests) response. The response will include headers indicating your rate limit status.
|
||||
|
||||
Best Practices
|
||||
1. Always use HTTPS to ensure secure communication.
|
||||
2. Store your clientId and clientSecret securely and never expose them in client-side code.
|
||||
3. Implement proper error handling in your applications.
|
||||
4. Respect rate limits and implement exponential backoff for retries.
|
||||
59
apps/public/content/docs/index.mdx
Normal 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.
|
||||
40
apps/public/content/docs/migration/beta-v1.mdx
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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
|
||||
|
||||
The `Openpanel` class is now called `OpenPanel`!
|
||||
|
||||
## Options
|
||||
|
||||
- Renamed: `api` to `apiUrl`
|
||||
- Added: `disabled`
|
||||
- Added: `filter`
|
||||
|
||||
## Methods
|
||||
- Renamed: `event` method is now called `track`
|
||||
- Renamed: `setProfile` and `setProfileId` is now called `identify` (and combined)
|
||||
- Changed: `increment('app_opened', 5)` is now `increment({ name: 'app_opened', value: 5, profileId: '123' })`. So profile ID is now required.
|
||||
- Changed: `decrement('app_opened', 5)` is now `decrement({ name: 'app_opened', value: 5, profileId: '123' })`. So profile ID is now required.
|
||||
- Improved: `screenView` method has 2 arguments now. This change is more aligned with `@openpanel/react-native`.
|
||||
```ts
|
||||
screenView(properties?: TrackProperties): void;
|
||||
screenView(path: string, properties?: TrackProperties): void;
|
||||
|
||||
// Example
|
||||
op.screenView('/home', { title: 'Home' }); // path will be "/home"
|
||||
op.screenView({ title: 'Home' }); // path will be what ever window.location.pathname is
|
||||
```
|
||||
|
||||
## Script tag
|
||||
|
||||
- New: `https://openpanel.dev/op1.js` should be used instead of `op.js` (note the filename)
|
||||
- Renamed: Tracking with attributes have changed. Use `data-track="my_event"` instead of `data-event="my_event"`
|
||||
|
||||
## @openpanel/nextjs
|
||||
|
||||
- Renamed: `OpenpanelProvider` to `OpenPanelComponent`
|
||||
- Removed: All exported methods (trackEvent etc). Use the `useOpenPanel` hook instead since these are client tracking only
|
||||
- Moved: `createNextRouteHandler` is moved to `@openpanel/nextjs/server`
|
||||
5
apps/public/content/docs/sdks/astro.mdx
Normal 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.
|
||||
80
apps/public/content/docs/sdks/express.mdx
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Express
|
||||
description: The Express middleware is a basic wrapper around Javascript SDK. It provides a simple way to add the SDK to your Express application.
|
||||
---
|
||||
|
||||
import Link from 'next/link';
|
||||
import { DeviceIdWarning } from '@/components/device-id-warning';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm install @openpanel/express
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The default export of `@openpanel/express` is a function that returns an Express middleware. It will also append the Openpanel SDK to the `req` object.
|
||||
|
||||
You can access it via `req.op`.
|
||||
|
||||
```ts
|
||||
import express from 'express';
|
||||
|
||||
import createOpenpanelMiddleware from '@openpanel/express';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
createOpenpanelMiddleware({
|
||||
clientId: 'xxx',
|
||||
clientSecret: 'xxx',
|
||||
// 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');
|
||||
});
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
|
||||
#### Express options
|
||||
|
||||
- `trackRequest` - A function that returns `true` if the request should be tracked.
|
||||
- `getProfileId` - A function that returns the profile ID of the user making the request.
|
||||
|
||||
## Typescript
|
||||
|
||||
If `req.op` is not typed you can extend the `Request` interface.
|
||||
|
||||
```ts
|
||||
import { OpenPanel } from '@openpanel/express';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
op: OpenPanel;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
140
apps/public/content/docs/sdks/javascript.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Javascript (Node / Generic)
|
||||
description: The OpenPanel Web SDK allows you to track user behavior on your website using a simple script tag. This guide provides instructions for installing and using the Web SDK in your project.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Step 1: Install
|
||||
|
||||
```bash
|
||||
npm install @openpanel/sdk
|
||||
```
|
||||
|
||||
### Step 2: Initialize
|
||||
|
||||
```js filename="op.ts"
|
||||
import { OpenPanel } from '@openpanel/sdk';
|
||||
|
||||
const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
clientSecret: 'YOUR_CLIENT_SECRET',
|
||||
});
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
|
||||
### Step 3: Usage
|
||||
|
||||
```js filename="main.ts"
|
||||
import { op } from './op.js';
|
||||
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Tracking Events
|
||||
|
||||
You can track events with two different methods: by calling the `op.track( directly or by adding `data-track` attributes to your HTML elements.
|
||||
|
||||
```ts filename="index.ts"
|
||||
import { op } from './op.ts';
|
||||
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user, call the `op.identify( method with a unique identifier.
|
||||
|
||||
```js filename="index.js"
|
||||
import { op } from './op.ts';
|
||||
|
||||
op.identify({
|
||||
profileId: '123', // Required
|
||||
firstName: 'Joe',
|
||||
lastName: 'Doe',
|
||||
email: 'joe@doe.com',
|
||||
properties: {
|
||||
tier: 'premium',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Setting Global Properties
|
||||
|
||||
To set properties that will be sent with every event:
|
||||
|
||||
```js filename="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.setGlobalProperties({
|
||||
app_version: '1.0.2',
|
||||
environment: 'production',
|
||||
});
|
||||
```
|
||||
|
||||
### Creating Aliases
|
||||
|
||||
To create an alias for a user:
|
||||
|
||||
```js filename="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.alias({
|
||||
alias: 'a1',
|
||||
profileId: '1'
|
||||
});
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
|
||||
To increment a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
|
||||
|
||||
```js filename="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.increment({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1 // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
|
||||
To decrement a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
|
||||
|
||||
```js filename="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.decrement({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1 // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
|
||||
```js filename="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.clear()
|
||||
```
|
||||
5
apps/public/content/docs/sdks/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "SDKs",
|
||||
"pages": ["script", "web", "javascript", "node", "nextjs", "..."],
|
||||
"defaultOpen": true
|
||||
}
|
||||
303
apps/public/content/docs/sdks/nextjs.mdx
Normal file
@@ -0,0 +1,303 @@
|
||||
---
|
||||
title: Next.js
|
||||
---
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
import { DeviceIdWarning } from '@/components/device-id-warning';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
## Good to know
|
||||
|
||||
Keep in mind that all tracking here happens on the client!
|
||||
|
||||
Read more about server side tracking in the [Server Side Tracking](#track-server-events) section.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install @openpanel/nextjs
|
||||
```
|
||||
|
||||
### Initialize
|
||||
|
||||
Add `OpenPanelComponent` to your root layout component.
|
||||
|
||||
```tsx
|
||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||
|
||||
export default RootLayout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
clientId="your-client-id"
|
||||
trackScreenViews={true}
|
||||
// trackAttributes={true}
|
||||
// trackOutgoingLinks={true}
|
||||
// If you have a user id, you can pass it here to identify the user
|
||||
// profileId={'123'}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
<WebSdkConfig />
|
||||
|
||||
##### NextJS options
|
||||
|
||||
- `profileId` - If you have a user id, you can pass it here to identify the user
|
||||
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
|
||||
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
|
||||
- `globalProperties` - This is an object of properties that will be sent with every event.
|
||||
|
||||
##### `filter`
|
||||
|
||||
This options needs to be a stringified function and cannot access any variables outside of the function.
|
||||
|
||||
```tsx
|
||||
<OpenPanelComponent
|
||||
clientId="your-client-id"
|
||||
filter={`
|
||||
function filter(event) {
|
||||
return event.name !== 'my_event';
|
||||
}
|
||||
`}
|
||||
/>
|
||||
```
|
||||
|
||||
To take advantage of typescript you can do the following. _Note `toString`_
|
||||
```tsx /.toString();/
|
||||
import { type OpenPanelOptions } from '@openpanel/nextjs';
|
||||
|
||||
const opFilter = ((event: TrackHandlerPayload) => {
|
||||
return event.type === 'track' && event.payload.name === 'my_event';
|
||||
}).toString();
|
||||
|
||||
<OpenPanelComponent
|
||||
clientId="your-client-id"
|
||||
filter={opFilter}
|
||||
/>
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Client components
|
||||
|
||||
For client components you can just use the `useOpenPanel` hook.
|
||||
|
||||
```tsx
|
||||
import { useOpenPanel } from '@openpanel/nextjs';
|
||||
|
||||
function YourComponent() {
|
||||
const op = useOpenPanel();
|
||||
|
||||
return <button type="button" onClick={() => op.track('my_event', { foo: 'bar' })}>Trigger event</button>
|
||||
}
|
||||
```
|
||||
|
||||
### Server components
|
||||
|
||||
Since you can't use hooks in server components, you need to create an instance of the SDK. This is exported from `@openpanel/nextjs`.
|
||||
|
||||
<Callout>Remember, your client secret is exposed here so do not use this on client side.</Callout>
|
||||
|
||||
```tsx filename="utils/op.ts"
|
||||
import { OpenPanel } from '@openpanel/nextjs';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'your-client-id',
|
||||
clientSecret: 'your-client-secret',
|
||||
});
|
||||
|
||||
// Now you can use `op` to track events
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
```
|
||||
|
||||
Refer to the [Javascript SDK](/docs/sdks/javascript#usage) for usage instructions.
|
||||
|
||||
### Tracking Events
|
||||
|
||||
You can track events with two different methods: by calling the `op.track( directly or by adding `data-track` attributes to your HTML elements.
|
||||
|
||||
```ts filename="index.ts"
|
||||
useOpenPanel().track('my_event', { foo: 'bar' });
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user, call the `op.identify( method with a unique identifier.
|
||||
|
||||
```js filename="index.js"
|
||||
useOpenPanel().identify({
|
||||
profileId: '123', // Required
|
||||
firstName: 'Joe',
|
||||
lastName: 'Doe',
|
||||
email: 'joe@doe.com',
|
||||
properties: {
|
||||
tier: 'premium',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### For server components
|
||||
|
||||
For server components you can use the `IdentifyComponent` component which is exported from `@openpanel/nextjs`.
|
||||
|
||||
> This component is great if you have the user data available on the server side.
|
||||
|
||||
```tsx filename="app/nested/layout.tsx"
|
||||
import { IdentifyComponent } from '@openpanel/nextjs';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
return (
|
||||
<>
|
||||
<IdentifyComponent
|
||||
profileId={user.id}
|
||||
firstName={user.firstName}
|
||||
lastName={user.lastName}
|
||||
email={user.email}
|
||||
properties={{
|
||||
tier: 'premium',
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Setting Global Properties
|
||||
|
||||
To set properties that will be sent with every event:
|
||||
|
||||
```js filename="index.js"
|
||||
useOpenPanel().setGlobalProperties({
|
||||
app_version: '1.0.2',
|
||||
environment: 'production',
|
||||
});
|
||||
```
|
||||
|
||||
### Creating Aliases
|
||||
|
||||
To create an alias for a user:
|
||||
|
||||
```js filename="index.js"
|
||||
useOpenPanel().alias({
|
||||
alias: 'a1',
|
||||
profileId: '1'
|
||||
});
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
|
||||
To increment a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
|
||||
|
||||
```js filename="index.js"
|
||||
useOpenPanel().increment({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1 // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
|
||||
To decrement a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
|
||||
|
||||
```js filename="index.js"
|
||||
useOpenPanel().decrement({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1 // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
|
||||
```js filename="index.js"
|
||||
useOpenPanel().clear()
|
||||
```
|
||||
|
||||
## Server side
|
||||
|
||||
If you want to track server-side events, you should create an instance of our Javascript SDK. It's exported from `@openpanel/nextjs`
|
||||
|
||||
<Callout>
|
||||
When using server events it's important that you use a secret to authenticate the request. This is to prevent unauthorized requests since we cannot use cors headers.
|
||||
|
||||
You can use the same clientId but you should pass the associated client secret to the SDK.
|
||||
|
||||
</Callout>
|
||||
|
||||
```typescript
|
||||
import { OpenpanelSdk } from '@openpanel/nextjs';
|
||||
|
||||
const opServer = new OpenpanelSdk({
|
||||
clientId: '{YOUR_CLIENT_ID}',
|
||||
clientSecret: '{YOUR_CLIENT_SECRET}',
|
||||
});
|
||||
|
||||
opServer.event('my_server_event', { ok: '✅' });
|
||||
|
||||
// Pass `profileId` to track events for a specific user
|
||||
opServer.event('my_server_event', { profileId: '123', ok: '✅' });
|
||||
```
|
||||
|
||||
### Serverless & Vercel
|
||||
|
||||
If you log events in a serverless environment like Vercel, you can use `waitUntil` to ensure the event is logged before the function is done.
|
||||
|
||||
Otherwise your function might close before the event is logged. Read more about it [here](https://vercel.com/docs/functions/functions-api-reference#waituntil).
|
||||
|
||||
```typescript
|
||||
import { waitUntil } from '@vercel/functions';
|
||||
import { opServer } from 'path/to/your-sdk-instance';
|
||||
|
||||
export function GET() {
|
||||
// Returns a response immediately while keeping the function alive
|
||||
waitUntil(opServer.event('my_server_event', { foo: 'bar' }));
|
||||
return new Response(`You're event has been logged!`);
|
||||
}
|
||||
```
|
||||
|
||||
### Proxy events
|
||||
|
||||
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains.
|
||||
|
||||
```typescript filename="/app/api/op/route.ts"
|
||||
import { createNextRouteHandler } from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
```
|
||||
|
||||
Remember to change the `apiUrl` in the `OpenPanelComponent` to your own server.
|
||||
|
||||
```tsx {2}
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op"
|
||||
clientId="your-client-id"
|
||||
trackScreenViews={true}
|
||||
/>
|
||||
```
|
||||
115
apps/public/content/docs/sdks/react-native.mdx
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: React Native
|
||||
---
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { DeviceIdWarning } from '@/components/device-id-warning';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Install dependencies
|
||||
|
||||
We're dependent on `expo-application` for `buildNumber`, `versionNumber` (and `referrer` on android) and `expo-constants` to get the `user-agent`.
|
||||
|
||||
```bash
|
||||
npm install @openpanel/react-native
|
||||
npx expo install expo-application expo-constants
|
||||
```
|
||||
|
||||
### Initialize
|
||||
|
||||
On native we use a clientSecret to authenticate the app.
|
||||
|
||||
```typescript
|
||||
const op = new Openpanel({
|
||||
clientId: '{YOUR_CLIENT_ID}',
|
||||
clientSecret: '{YOUR_CLIENT_SECRET}',
|
||||
});
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Track event
|
||||
|
||||
```typescript
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
```
|
||||
|
||||
### Navigation / Screen views
|
||||
|
||||
<Tabs items={['expo-router', 'react-navigation (simple)']}>
|
||||
<Tab value="expo-router">
|
||||
```typescript
|
||||
import { usePathname, useSegments } from 'expo-router';
|
||||
|
||||
const op = new Openpanel({ /* ... */ })
|
||||
|
||||
function RootLayout() {
|
||||
// ...
|
||||
const pathname = usePathname()
|
||||
// Segments is optional but can be nice to have if you
|
||||
// want to group routes together
|
||||
// pathname = /posts/123
|
||||
// segements = ['posts', '[id]']
|
||||
const segments = useSegments()
|
||||
|
||||
useEffect(() => {
|
||||
// Simple
|
||||
op.screenView(pathname)
|
||||
|
||||
// With extra data
|
||||
op.screenView(pathname, {
|
||||
// segments is optional but nice to have
|
||||
segments: segments.join('/'),
|
||||
// other optional data you want to send with the screen view
|
||||
})
|
||||
}, [pathname,segments])
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value="react-navigation (simple)">
|
||||
```tsx
|
||||
import { createNavigationContainerRef } from '@react-navigation/native'
|
||||
import { Openpanel } from '@openpanel/react-native'
|
||||
|
||||
const op = new Openpanel({ /* ... */ })
|
||||
const navigationRef = createNavigationContainerRef()
|
||||
|
||||
export function NavigationRoot() {
|
||||
const handleNavigationStateChange = () => {
|
||||
const current = navigationRef.getCurrentRoute()
|
||||
if (current) {
|
||||
op.screenView(current.name, {
|
||||
params: current.params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
onReady={handleNavigationStateChange}
|
||||
onStateChange={handleNavigationStateChange}
|
||||
>
|
||||
<Stack.Navigator />
|
||||
</NavigationContainer>
|
||||
)
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For more information on how to use the SDK, check out the [Javascript SDK](/docs/sdks/javascript#usage).
|
||||
5
apps/public/content/docs/sdks/react.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: React
|
||||
---
|
||||
|
||||
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated react sdk soon.
|
||||
5
apps/public/content/docs/sdks/remix.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Remix
|
||||
---
|
||||
|
||||
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated remix sdk soon.
|
||||
211
apps/public/content/docs/sdks/script.mdx
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
title: Script Tag
|
||||
description: The OpenPanel Web SDK allows you to track user behavior on your website using a simple script tag. This guide provides instructions for installing and using the Web SDK in your project.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Just insert this snippet and replace `YOUR_CLIENT_ID` with your client id.
|
||||
|
||||
```html filename="index.html" /clientId: 'YOUR_CLIENT_ID'/
|
||||
<script>
|
||||
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
<WebSdkConfig />
|
||||
|
||||
## Usage
|
||||
|
||||
### Tracking Events
|
||||
|
||||
You can track events with two different methods: by calling the `window.op('track')` directly or by adding `data-track` attributes to your HTML elements.
|
||||
|
||||
```html filename="index.html"
|
||||
<button type="button" onclick="window.op('track', 'my_event', { foo: 'bar' })">
|
||||
Track event
|
||||
</button>
|
||||
```
|
||||
|
||||
```html filename="index.html"
|
||||
<button type="button" data-track="my_event" data-foo="bar">Track event</button>
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user, call the `window.op('identify')` method with a unique identifier.
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('identify', {
|
||||
profileId: '123', // Required
|
||||
firstName: 'Joe',
|
||||
lastName: 'Doe',
|
||||
email: 'joe@doe.com',
|
||||
properties: {
|
||||
tier: 'premium',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Setting Global Properties
|
||||
|
||||
To set properties that will be sent with every event:
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('setGlobalProperties', {
|
||||
app_version: '1.0.2',
|
||||
environment: 'production',
|
||||
});
|
||||
```
|
||||
|
||||
### Creating Aliases
|
||||
|
||||
To create an alias for a user:
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('alias', {
|
||||
alias: 'a1',
|
||||
profileId: '1'
|
||||
});
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
|
||||
To increment a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('increment', {
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1 // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
|
||||
To decrement a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('decrement', {
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1 // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('clear');
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Filtering events
|
||||
|
||||
You can filter out events by adding a `filter` property to the `init` method.
|
||||
|
||||
Below is an example of how to disable tracking for users who have a `disable_tracking` item in their local storage.
|
||||
|
||||
```js filename="main.js"
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
filter: () => localStorage.getItem('disable_tracking') === undefined,
|
||||
});
|
||||
```
|
||||
|
||||
### Using the Web SDK with NPM
|
||||
|
||||
<Steps>
|
||||
#### Step 1: Install the SDK
|
||||
|
||||
```bash
|
||||
npm install @openpanel/web
|
||||
```
|
||||
|
||||
#### Step 2: Initialize the SDK
|
||||
|
||||
```js filename="op.js"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### Step 3: Use the SDK
|
||||
|
||||
```js filename="main.js"
|
||||
import { op } from './op.js';
|
||||
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
```
|
||||
</Steps>
|
||||
|
||||
### Typescript
|
||||
|
||||
Getting ts errors when using the SDK? You can add a custom type definition file to your project.
|
||||
|
||||
#### Simple
|
||||
|
||||
Just paste this code in any of your `.d.ts` files.
|
||||
|
||||
```ts filename="op.d.ts"
|
||||
declare global {
|
||||
interface Window {
|
||||
op: {
|
||||
q?: string[][];
|
||||
(...args: [
|
||||
'init' | 'track' | 'identify' | 'setGlobalProperties' | 'alias' | 'increment' | 'decrement' | 'clear',
|
||||
...any[]
|
||||
]): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Strict typing (from sdk)
|
||||
|
||||
<Steps>
|
||||
##### Step 1: Install the SDK
|
||||
|
||||
```bash
|
||||
npm install @openpanel/web
|
||||
```
|
||||
|
||||
##### Step 2: Create a type definition file
|
||||
|
||||
Create a `op.d.ts`file and paste the following code:
|
||||
|
||||
```ts filename="op.d.ts"
|
||||
/// <reference types="@openpanel/web" />
|
||||
```
|
||||
</Steps>
|
||||
5
apps/public/content/docs/sdks/vue.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Vue
|
||||
---
|
||||
|
||||
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated react sdk soon.
|
||||
49
apps/public/content/docs/sdks/web.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Javascript (Web)
|
||||
description: The OpenPanel Web SDK allows you to track user behavior on your website using a simple script tag. This guide provides instructions for installing and using the Web SDK in your project.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Step 1: Install
|
||||
|
||||
```bash
|
||||
npm install @openpanel/web
|
||||
```
|
||||
|
||||
### Step 2: Initialize
|
||||
|
||||
```js filename="op.ts"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
<WebSdkConfig />
|
||||
|
||||
### Step 3: Usage
|
||||
|
||||
```js filename="main.ts"
|
||||
import { op } from './op.js';
|
||||
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
Refer to the [Javascript SDK](/docs/sdks/javascript#usage) for usage instructions.
|
||||
142
apps/public/content/docs/self-hosting/self-hosting.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Self-hosting
|
||||
description: This is a simple guide how to get started with OpenPanel on your own VPS.
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
|
||||
|
||||
<Callout>OpenPanel is not stable yet. If you still want to self-host you can go ahead. Bear in mind that new changes might give a little headache to keep up with.</Callout>
|
||||
|
||||
|
||||
|
||||
## Instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- VPS of any kind (only tested on Ubuntu 24.04)
|
||||
- 🙋♂️ This should work on any system if you have pre-installed docker, node and pnpm
|
||||
- [Clerk.com](https://clerk.com) account (they have a free tier)
|
||||
|
||||
### Quickstart
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hosting && ./setup
|
||||
# After setup is complete run `./start` to start OpenPanel
|
||||
```
|
||||
|
||||
<Steps>
|
||||
|
||||
### Clone
|
||||
|
||||
Clone the repository to your VPS
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Openpanel-dev/openpanel.git
|
||||
```
|
||||
|
||||
### Run the setup script
|
||||
|
||||
The setup script will do 3 things
|
||||
|
||||
1. Install node (if you accept)
|
||||
2. Install docker (if you accept)
|
||||
3. Execute a node script that will ask some questions about your setup
|
||||
4. After this is done you'll need to point a webhook inside Clerk (https://your-domain.com/api/webhook/clerk)
|
||||
|
||||
> Setup takes 1-2 minutes depending on your VPS
|
||||
|
||||
```bash
|
||||
cd openpanel/self-hosting
|
||||
./setup
|
||||
```
|
||||
|
||||
⚠️ If the `./setup` script fails to run, you can do it manually.
|
||||
|
||||
1. Install docker
|
||||
2. Install node
|
||||
3. Install pnpm
|
||||
4. Run the `npx jiti ./quiz.ts` script inside the self-hosting folder
|
||||
|
||||
### Start 🚀
|
||||
|
||||
Run the `./start` script located inside the self-hosting folder
|
||||
|
||||
```bash
|
||||
./start
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## Clerk.com
|
||||
|
||||
<Callout>
|
||||
Some might wonder why we use Clerk.com for authentication. The main reason for this is that Clerk have great support for iOS and Android apps. We're in the process of building an native app and we want to have a seamless experience for our users.
|
||||
|
||||
**next-auth** is great, but lacks good support for mobile apps.
|
||||
</Callout>
|
||||
|
||||
You'll need to create an account at [Clerk.com](https://clerk.com) and create a new project. You'll need the 3 keys that Clerk provides you with.
|
||||
|
||||
- **Publishable key** `pk_live_xxx`
|
||||
- **Secret key** `sk_live_xxx`
|
||||
- **Signing secret** `"whsec_xxx"`
|
||||
|
||||
### Webhooks
|
||||
|
||||
You'll also need to add a webhook to your domain. We listen on some events from Clerk to keep our database in sync.
|
||||
|
||||
#### URL
|
||||
|
||||
- **Path**: `/api/webhook/clerk`
|
||||
- **Example**: `https://your-domain.com/api/webhook/clerk`
|
||||
|
||||
#### Events we listen to
|
||||
|
||||
- `organizationMembership.created`
|
||||
- `user.created`
|
||||
- `organizationMembership.deleted`
|
||||
- `user.updated`
|
||||
- `user.deleted`
|
||||
|
||||
## Good to know
|
||||
|
||||
### Always use correct api url
|
||||
|
||||
When self-hosting you'll need to provide your api url when initializing the SDK.
|
||||
|
||||
The path should be `/api` and the domain should be your domain.
|
||||
|
||||
```html filename="index.html" {4}
|
||||
<script>
|
||||
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
|
||||
window.op('init', {
|
||||
apiUrl: 'https://your-domain.com/api',
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
```
|
||||
|
||||
```js filename="op.ts" {4}
|
||||
import { OpenPanel } from '@openpanel/sdk';
|
||||
|
||||
const op = new OpenPanel({
|
||||
apiUrl: 'https://your-domain.com/api',
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Managed Redis
|
||||
|
||||
If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually.
|
||||
|
||||
Without this setting we wont be able to listen for expired keys which we use for caluclating currently active vistors.
|
||||
|
||||
> You will see a warning in the logs if this needs to be set manually.
|
||||
66
apps/public/content/pages/about.mdx
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: About
|
||||
description: Learn about who we are and why we started this company. We're on a mission to help businesses work better with their data.
|
||||
---
|
||||
|
||||
|
||||
## Introduction
|
||||
|
||||
Hey there!
|
||||
|
||||
My name is [Carl-Gerhard Lindesvärd](https://x.com/CarlLindesvard). I'm the founder of OpenPanel. These days, knowing how your users use your product is super important.
|
||||
|
||||
I've worked with different analytics tools for over 5 years, and I've seen it all - the good, the bad, and the ugly.
|
||||
|
||||
I've mostly used Mixpanel along with tools like Google Analytics. Here's why I decided to start OpenPanel.
|
||||
|
||||
## Why
|
||||
|
||||
### Product analytics
|
||||
|
||||
Mixpanel is a solid analytics tool - it's easy to create reports and has tons of features.
|
||||
|
||||
But I was only using a small part of it, mainly the reports. I wanted a simple way to see my custom events. Sure, Mixpanel lets you make nice reports with different charts like:
|
||||
|
||||
- Line charts
|
||||
- Bar charts
|
||||
- Pie charts
|
||||
- Funnels
|
||||
- Retention
|
||||
- ...and more
|
||||
|
||||
Being able to track what each user does was great too. But that was pretty much all I needed.
|
||||
|
||||
The problem? I had to pay $300/month for this. Their pricing makes no sense, especially since I only used a tiny bit of what they offer.
|
||||
|
||||
### Web analytics
|
||||
|
||||
Mixpanel does web analytics too, but not in a way that works for me. I just wanted a simple dashboard to quickly see how my website is doing. That's why I ended up needing a separate tool just for web analytics.
|
||||
|
||||
## The solution
|
||||
|
||||
That's why I built OpenPanel. It's a simple analytics tool that I actually use myself. It combines both product and web analytics:
|
||||
|
||||
- You get a clear picture of your website's performance and can dig deeper when needed
|
||||
- You can build reports that show your data in ways that make sense to you
|
||||
- You can see what your users are doing, both individually and as a group
|
||||
- Unlike Mixpanel, you can check your stats from your phone since our dashboard works great on mobile
|
||||
|
||||
## OpenPanel is a great alternative to Mixpanel
|
||||
|
||||
Now you know a bit about me and why I think OpenPanel is a solid alternative to Mixpanel. Got questions? Want to know more? Feel free to [contact me](/contact).
|
||||
|
||||
You can read more about why we think OpenPanel is a [good alternative to Mixpanel](/articles/vs-mixpanel).
|
||||
|
||||
## Company
|
||||
|
||||
OpenPanel (OpenPanel AB) is a company based in Stockholm, Sweden.
|
||||
|
||||
### Address
|
||||
|
||||
<pre className="p-0">
|
||||
OpenPanel AB
|
||||
Sankt Eriksgatan 100
|
||||
113 31 Stockholm
|
||||
Sweden
|
||||
</pre>
|
||||
16
apps/public/content/pages/contact.mdx
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Contact
|
||||
description: Get in touch with the founder of OpenPanel - a simple and affordable alternative to Mixpanel combining product and web analytics.
|
||||
---
|
||||
|
||||
## Mediums to contact me:
|
||||
|
||||
- [Email](mailto:hello@openpanel.dev)
|
||||
- [X (@OpenPanelDev)](https://x.com/OpenPanelDev)
|
||||
- [X (@CarlLindesvard)](https://x.com/CarlLindesvard)
|
||||
- [Discord](https://discord.gg/openpanel)
|
||||
- [Github](https://github.com/Openpanel-dev/openpanel/)
|
||||
|
||||
## Issues or feature requests
|
||||
|
||||
If you have any issues or feature requests, please let me know by [opening an issue on Github](https://github.com/Openpanel-dev/openpanel/issues) or join our [Discord](https://discord.gg/openpanel).
|
||||
24
apps/public/content/pages/cookies.mdx
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Cookies
|
||||
description: Cookie monster's policy
|
||||
---
|
||||
|
||||
We use cookies and similar technologies to help provide, protect, and improve your experience on our website. This policy explains how and why we use these technologies and the choices you have.
|
||||
|
||||
## What are cookies?
|
||||
|
||||
Cookies are small data files that are placed on your computer or mobile device when you visit a website. They are widely used to make websites work more efficiently and provide useful information to website owners.
|
||||
|
||||
## How we use cookies
|
||||
|
||||
We use cookies for several purposes, including:
|
||||
- **Essential cookies**: Required for basic site functionality
|
||||
- **Analytics cookies**: Help us understand how visitors interact with our website
|
||||
- **Preference cookies**: Remember your settings and preferences
|
||||
|
||||
## Your choices
|
||||
|
||||
Most web browsers allow you to control cookies through their settings preferences. However, limiting cookies may impact your experience using our website.
|
||||
|
||||
For more information about cookies, visit [AllAboutCookies.org](https://www.allaboutcookies.org/).
|
||||
|
||||
18
apps/public/content/pages/pricing.mdx
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Pricing
|
||||
description: Our simple, usage-based pricing means you only pay for what you use. Scale effortlessly for the best value.
|
||||
---
|
||||
import Pricing from '@/components/sections/pricing';
|
||||
import Stats from '@/components/sections/stats';
|
||||
import Testimonials from '@/components/sections/testimonials';
|
||||
import Faq from '@/components/sections/Faq';
|
||||
|
||||
Experience transparent, usage-based pricing that grows with your needs. Simply choose your monthly event volume and pay accordingly - no surprises.
|
||||
|
||||
All features are included in every plan, with no hidden fees or artificial feature restrictions. What you see is what you get.
|
||||
|
||||
<div className="lg:-mx-20 xl:-mx-40 not-prose -mt-16">
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
</div>
|
||||
212
apps/public/content/pages/privacy.mdx
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
title: Privacy Policy
|
||||
description: Our privacy policy outlines how we handle your data, including usage information and cookies, to provide and improve our services.
|
||||
---
|
||||
|
||||
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.
|
||||
|
||||
We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.
|
||||
|
||||
## Interpretation and Definitions
|
||||
|
||||
### Interpretation
|
||||
|
||||
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
|
||||
|
||||
### Definitions
|
||||
|
||||
For the purposes of this Privacy Policy:
|
||||
|
||||
- **Account** means a unique account created for You to access our Service or parts of our Service.
|
||||
|
||||
- **Affiliate** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.
|
||||
|
||||
- **Application** refers to Openpanel, the software program provided by the Company.
|
||||
|
||||
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
|
||||
|
||||
- **Cookies** are small files that are placed on Your computer, mobile device or any other device by a website, containing the details of Your browsing history on that website among its many uses.
|
||||
|
||||
- **Country** refers to: Sweden
|
||||
|
||||
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
|
||||
|
||||
- **Personal Data** is any information that relates to an identified or identifiable individual.
|
||||
|
||||
- **Service** refers to the Application or the Website or both.
|
||||
|
||||
- **Service Provider** means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service or to assist the Company in analyzing how the Service is used.
|
||||
|
||||
- **Usage Data** refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).
|
||||
|
||||
- **Website** refers to Openpanel, accessible from [https://openpanel.dev](https://openpanel.dev)
|
||||
|
||||
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
|
||||
|
||||
## Collecting and Using Your Personal Data
|
||||
|
||||
### Types of Data Collected
|
||||
|
||||
#### Personal Data
|
||||
|
||||
While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:
|
||||
|
||||
- Email address
|
||||
- First name and last name
|
||||
- Usage Data
|
||||
|
||||
#### Usage Data
|
||||
|
||||
Usage Data is collected automatically when using the Service.
|
||||
|
||||
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
|
||||
|
||||
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.
|
||||
|
||||
We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.
|
||||
|
||||
#### Tracking Technologies and Cookies
|
||||
|
||||
We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:
|
||||
|
||||
- **Cookies or Browser Cookies.** A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse Cookies, our Service may use Cookies.
|
||||
|
||||
- **Web Beacons.** Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).
|
||||
|
||||
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser. You can learn more about cookies [here](https://www.termsfeed.com/blog/cookies/#What_Are_Cookies).
|
||||
|
||||
We use both Session and Persistent Cookies for the purposes set out below:
|
||||
|
||||
- **Necessary / Essential Cookies**
|
||||
|
||||
Type: Session Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.
|
||||
|
||||
- **Cookies Policy / Notice Acceptance Cookies**
|
||||
|
||||
Type: Persistent Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies identify if users have accepted the use of cookies on the Website.
|
||||
|
||||
- **Functionality Cookies**
|
||||
|
||||
Type: Persistent Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.
|
||||
|
||||
For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.
|
||||
|
||||
### Use of Your Personal Data
|
||||
|
||||
The Company may use Personal Data for the following purposes:
|
||||
|
||||
- **To provide and maintain our Service**, including to monitor the usage of our Service.
|
||||
|
||||
- **To manage Your Account:** to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.
|
||||
|
||||
- **For the performance of a contract:** the development, compliance and undertaking of the purchase contract for the products, items or services You have purchased or of any other contract with Us through the Service.
|
||||
|
||||
- **To contact You:** To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application's push notifications regarding updates or informative communications related to the functionalities, products or contracted services, including the security updates, when necessary or reasonable for their implementation.
|
||||
|
||||
- **To provide You** with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless You have opted not to receive such information.
|
||||
|
||||
- **To manage Your requests:** To attend and manage Your requests to Us.
|
||||
|
||||
- **For business transfers:** We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar proceeding, in which Personal Data held by Us about our Service users is among the assets transferred.
|
||||
|
||||
- **For other purposes**: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service, products, services, marketing and your experience.
|
||||
|
||||
We may share Your personal information in the following situations:
|
||||
|
||||
- **With Service Providers:** We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.
|
||||
|
||||
- **For business transfers:** We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.
|
||||
|
||||
- **With Affiliates:** We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners or other companies that We control or that are under common control with Us.
|
||||
|
||||
- **With business partners:** We may share Your information with Our business partners to offer You certain products, services or promotions.
|
||||
|
||||
- **With other users:** when You share personal information or otherwise interact in the public areas with other users, such information may be viewed by all users and may be publicly distributed outside.
|
||||
|
||||
- **With Your consent**: We may disclose Your personal information for any other purpose with Your consent.
|
||||
|
||||
### Retention of Your Personal Data
|
||||
|
||||
The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.
|
||||
|
||||
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
|
||||
|
||||
### Transfer of Your Personal Data
|
||||
|
||||
Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.
|
||||
|
||||
Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.
|
||||
|
||||
The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.
|
||||
|
||||
### Delete Your Personal Data
|
||||
|
||||
You have the right to delete or request that We assist in deleting the Personal Data that We have collected about You.
|
||||
|
||||
Our Service may give You the ability to delete certain information about You from within the Service.
|
||||
|
||||
You may update, amend, or delete Your information at any time by signing in to Your Account, if you have one, and visiting the account settings section that allows you to manage Your personal information. You may also contact Us to request access to, correct, or delete any personal information that You have provided to Us.
|
||||
|
||||
Please note, however, that We may need to retain certain information when we have a legal obligation or lawful basis to do so.
|
||||
|
||||
### Disclosure of Your Personal Data
|
||||
|
||||
#### Business Transactions
|
||||
|
||||
If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.
|
||||
|
||||
#### Law enforcement
|
||||
|
||||
Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).
|
||||
|
||||
#### Other legal requirements
|
||||
|
||||
The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:
|
||||
|
||||
- Comply with a legal obligation
|
||||
- Protect and defend the rights or property of the Company
|
||||
- Prevent or investigate possible wrongdoing in connection with the Service
|
||||
- Protect the personal safety of Users of the Service or the public
|
||||
- Protect against legal liability
|
||||
|
||||
### Security of Your Personal Data
|
||||
|
||||
The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.
|
||||
|
||||
If We need to rely on consent as a legal basis for processing Your information and Your country requires consent from a parent, We may require Your parent's consent before We collect and use that information.
|
||||
|
||||
## Links to Other Websites
|
||||
|
||||
Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review the Privacy Policy of every site You visit.
|
||||
|
||||
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
|
||||
|
||||
## Changes to this Privacy Policy
|
||||
|
||||
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.
|
||||
|
||||
We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.
|
||||
|
||||
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have any questions about this Privacy Policy, You can contact us:
|
||||
|
||||
- By email: hello@openpanel.dev
|
||||
192
apps/public/content/pages/terms.mdx
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: Terms of Service
|
||||
description: Legal terms and conditions governing the use of Openpanel's services and website.
|
||||
---
|
||||
|
||||
Please read these terms and conditions carefully before using Our Service.
|
||||
|
||||
## Interpretation and Definitions
|
||||
|
||||
### Interpretation
|
||||
|
||||
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
|
||||
|
||||
### Definitions
|
||||
|
||||
For the purposes of these Terms and Conditions:
|
||||
|
||||
- **Application** means the software program provided by the Company downloaded by You on any electronic device, named Openpanel
|
||||
|
||||
- **Application Store** means the digital distribution service operated and developed by Apple Inc. (Apple App Store) or Google Inc. (Google Play Store) in which the Application has been downloaded.
|
||||
|
||||
- **Affiliate** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.
|
||||
|
||||
- **Account** means a unique account created for You to access our Service or parts of our Service.
|
||||
|
||||
- **Country** refers to: Sweden
|
||||
|
||||
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
|
||||
|
||||
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
|
||||
|
||||
- **Free Trial** refers to a limited period of time that may be free when purchasing a Subscription.
|
||||
|
||||
- **Service** refers to the Application or the Website or both.
|
||||
|
||||
- **Subscriptions** refer to the services or access to the Service offered on a subscription basis by the Company to You.
|
||||
|
||||
- **Terms and Conditions** (also referred as "Terms") mean these Terms and Conditions that form the entire agreement between You and the Company regarding the use of the Service.
|
||||
|
||||
- **Third-party Social Media Service** means any services or content (including data, information, products or services) provided by a third-party that may be displayed, included or made available by the Service.
|
||||
|
||||
- **Website** refers to Openpanel, accessible from [https://openpanel.dev](https://openpanel.dev)
|
||||
|
||||
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
These are the Terms and Conditions governing the use of this Service and the agreement that operates between You and the Company. These Terms and Conditions set out the rights and obligations of all users regarding the use of the Service.
|
||||
|
||||
Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and Conditions. These Terms and Conditions apply to all visitors, users and others who access or use the Service.
|
||||
|
||||
By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part of these Terms and Conditions then You may not access the Service.
|
||||
|
||||
You represent that you are over the age of 18. The Company does not permit those under 18 to use the Service.
|
||||
|
||||
Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
### Subscription period
|
||||
|
||||
The Service or some parts of the Service are available only with a paid Subscription. You will be billed in advance on a recurring and periodic basis (such as daily, weekly, monthly or annually), depending on the type of Subscription plan you select when purchasing the Subscription.
|
||||
|
||||
At the end of each period, Your Subscription will automatically renew under the exact same conditions unless You cancel it or the Company cancels it.
|
||||
|
||||
### Subscription cancellations
|
||||
|
||||
You may cancel Your Subscription renewal either through Your Account settings page or by contacting the Company. You will not receive a refund for the fees You already paid for Your current Subscription period and You will be able to access the Service until the end of Your current Subscription period.
|
||||
|
||||
### Billing
|
||||
|
||||
You shall provide the Company with accurate and complete billing information including full name, address, state, zip code, telephone number, and a valid payment method information.
|
||||
|
||||
Should automatic billing fail to occur for any reason, the Company will issue an electronic invoice indicating that you must proceed manually, within a certain deadline date, with the full payment corresponding to the billing period as indicated on the invoice.
|
||||
|
||||
### Fee Changes
|
||||
|
||||
The Company, in its sole discretion and at any time, may modify the Subscription fees. Any Subscription fee change will become effective at the end of the then-current Subscription period.
|
||||
|
||||
The Company will provide You with reasonable prior notice of any change in Subscription fees to give You an opportunity to terminate Your Subscription before such change becomes effective.
|
||||
|
||||
Your continued use of the Service after the Subscription fee change comes into effect constitutes Your agreement to pay the modified Subscription fee amount.
|
||||
|
||||
### Refunds
|
||||
|
||||
Except when required by law, paid Subscription fees are non-refundable.
|
||||
|
||||
Certain refund requests for Subscriptions may be considered by the Company on a case-by-case basis and granted at the sole discretion of the Company.
|
||||
|
||||
### Free Trial
|
||||
|
||||
The Company may, at its sole discretion, offer a Subscription with a Free Trial for a limited period of time.
|
||||
|
||||
You may be required to enter Your billing information in order to sign up for the Free Trial.
|
||||
|
||||
If You do enter Your billing information when signing up for a Free Trial, You will not be charged by the Company until the Free Trial has expired. On the last day of the Free Trial period, unless You canceled Your Subscription, You will be automatically charged the applicable Subscription fees for the type of Subscription You have selected.
|
||||
|
||||
At any time and without notice, the Company reserves the right to (i) modify the terms and conditions of the Free Trial offer, or (ii) cancel such Free Trial offer.
|
||||
|
||||
## User Accounts
|
||||
|
||||
When You create an account with Us, You must provide Us information that is accurate, complete, and current at all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of Your account on Our Service.
|
||||
|
||||
You are responsible for safeguarding the password that You use to access the Service and for any activities or actions under Your password, whether Your password is with Our Service or a Third-Party Social Media Service.
|
||||
|
||||
You agree not to disclose Your password to any third party. You must notify Us immediately upon becoming aware of any breach of security or unauthorized use of Your account.
|
||||
|
||||
You may not use as a username the name of another person or entity or that is not lawfully available for use, a name or trademark that is subject to any rights of another person or entity other than You without appropriate authorization, or a name that is otherwise offensive, vulgar or obscene.
|
||||
|
||||
## Intellectual Property
|
||||
|
||||
The Service and its original content (excluding Content provided by You or other users), features and functionality are and will remain the exclusive property of the Company and its licensors.
|
||||
|
||||
The Service is protected by copyright, trademark, and other laws of both the Country and foreign countries.
|
||||
|
||||
Our trademarks and trade dress may not be used in connection with any product or service without the prior written consent of the Company.
|
||||
|
||||
## Links to Other Websites
|
||||
|
||||
Our Service may contain links to third-party web sites or services that are not owned or controlled by the Company.
|
||||
|
||||
The Company has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the Company shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such content, goods or services available on or through any such web sites or services.
|
||||
|
||||
We strongly advise You to read the terms and conditions and privacy policies of any third-party web sites or services that You visit.
|
||||
|
||||
## Termination
|
||||
|
||||
We may terminate or suspend Your Account immediately, without prior notice or liability, for any reason whatsoever, including without limitation if You breach these Terms and Conditions.
|
||||
|
||||
Upon termination, Your right to use the Service will cease immediately. If You wish to terminate Your Account, You may simply discontinue using the Service.
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
Notwithstanding any damages that You might incur, the entire liability of the Company and any of its suppliers under any provision of this Terms and Your exclusive remedy for all of the foregoing shall be limited to the amount actually paid by You through the Service or 100 USD if You haven't purchased anything through the Service.
|
||||
|
||||
To the maximum extent permitted by applicable law, in no event shall the Company or its suppliers be liable for any special, incidental, indirect, or consequential damages whatsoever (including, but not limited to, damages for loss of profits, loss of data or other information, for business interruption, for personal injury, loss of privacy arising out of or in any way related to the use of or inability to use the Service, third-party software and/or third-party hardware used with the Service, or otherwise in connection with any provision of this Terms), even if the Company or any supplier has been advised of the possibility of such damages and even if the remedy fails of its essential purpose.
|
||||
|
||||
Some states do not allow the exclusion of implied warranties or limitation of liability for incidental or consequential damages, which means that some of the above limitations may not apply. In these states, each party's liability will be limited to the greatest extent permitted by law.
|
||||
|
||||
## "AS IS" and "AS AVAILABLE" Disclaimer
|
||||
|
||||
The Service is provided to You "AS IS" and "AS AVAILABLE" and with all faults and defects without warranty of any kind. To the maximum extent permitted under applicable law, the Company, on its own behalf and on behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all warranties, whether express, implied, statutory or otherwise, with respect to the Service, including all implied warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may arise out of course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, the Company provides no warranty or undertaking, and makes no representation of any kind that the Service will meet Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems or services, operate without interruption, meet any performance or reliability standards or be error free or that any errors or defects can or will be corrected.
|
||||
|
||||
Without limiting the foregoing, neither the Company nor any of the company's provider makes any representation or warranty of any kind, express or implied: (i) as to the operation or availability of the Service, or the information, content, and materials or products included thereon; (ii) that the Service will be uninterrupted or error-free; (iii) as to the accuracy, reliability, or currency of any information or content provided through the Service; or (iv) that the Service, its servers, the content, or e-mails sent from or on behalf of the Company are free of viruses, scripts, trojan horses, worms, malware, timebombs or other harmful components.
|
||||
|
||||
Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory rights of a consumer, so some or all of the above exclusions and limitations may not apply to You. But in such a case the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under applicable law.
|
||||
|
||||
## Governing Law
|
||||
|
||||
The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. Your use of the Application may also be subject to other local, state, national, or international laws.
|
||||
|
||||
## Disputes Resolution
|
||||
|
||||
If You have any concern or dispute about the Service, You agree to first try to resolve the dispute informally by contacting the Company.
|
||||
|
||||
## For European Union (EU) Users
|
||||
|
||||
If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which You are resident.
|
||||
|
||||
## United States Federal Government End Use Provisions
|
||||
|
||||
If You are a U.S. federal government end user, our Service is a "Commercial Item" as that term is defined at 48 C.F.R. §2.101.
|
||||
|
||||
## United States Legal Compliance
|
||||
|
||||
You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.
|
||||
|
||||
## Severability and Waiver
|
||||
|
||||
### Severability
|
||||
|
||||
If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and interpreted to accomplish the objectives of such provision to the greatest extent possible under applicable law and the remaining provisions will continue in full force and effect.
|
||||
|
||||
### Waiver
|
||||
|
||||
Except as provided herein, the failure to exercise a right or to require performance of an obligation under these Terms shall not affect a party's ability to exercise such right or require such performance at any time thereafter nor shall the waiver of a breach constitute a waiver of any subsequent breach.
|
||||
|
||||
## Translation Interpretation
|
||||
|
||||
These Terms and Conditions may have been translated if We have made them available to You on our Service. You agree that the original English text shall prevail in the case of a dispute.
|
||||
|
||||
## Changes to These Terms and Conditions
|
||||
|
||||
We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. If a revision is material We will make reasonable efforts to provide at least 30 days' notice prior to any new terms taking effect. What constitutes a material change will be determined at Our sole discretion.
|
||||
|
||||
By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the revised terms. If You do not agree to the new terms, in whole or in part, please stop using the website and the Service.
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have any questions about these Terms and Conditions, You can contact us:
|
||||
|
||||
- By email: hello@openpanel.dev
|
||||
37
apps/public/lib/dark-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useIsDarkMode() {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Check localStorage first
|
||||
const savedTheme = window.localStorage.getItem('theme');
|
||||
if (savedTheme !== null) {
|
||||
return savedTheme === 'dark';
|
||||
}
|
||||
|
||||
// Fall back to system preference
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user prefers dark mode
|
||||
const htmlElement = document.documentElement;
|
||||
setIsDarkMode(htmlElement.classList.contains('dark'));
|
||||
|
||||
// Create observer to watch for class changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(htmlElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing class changes on html element
|
||||
observer.observe(htmlElement, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
6
apps/public/lib/github.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export async function getGithubRepoInfo() {
|
||||
const res = await fetch(
|
||||
'https://api.github.com/repos/Openpanel-dev/openpanel',
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
25
apps/public/lib/source.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
articleCollection,
|
||||
articleMeta,
|
||||
docs,
|
||||
meta,
|
||||
pageCollection,
|
||||
pageMeta,
|
||||
} from '@/.source';
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { createMDXSource } from 'fumadocs-mdx';
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: '/docs',
|
||||
source: createMDXSource(docs, meta),
|
||||
});
|
||||
|
||||
export const articleSource = loader({
|
||||
baseUrl: '/articles',
|
||||
source: createMDXSource(articleCollection, articleMeta),
|
||||
});
|
||||
|
||||
export const pageSource = loader({
|
||||
baseUrl: '/',
|
||||
source: createMDXSource(pageCollection, pageMeta),
|
||||
});
|
||||
6
apps/public/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,28 +1,22 @@
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
await import('./src/env.mjs');
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const withMDX = createMDX();
|
||||
|
||||
/** @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'],
|
||||
defaultLocale: 'en',
|
||||
reactStrictMode: false,
|
||||
images: {
|
||||
domains: ['localhost', 'openpanel.dev'],
|
||||
},
|
||||
transpilePackages: [
|
||||
'@openpanel/queue',
|
||||
'@openpanel/db',
|
||||
'@openpanel/common',
|
||||
'@openpanel/constants',
|
||||
'@openpanel/redis',
|
||||
'@openpanel/validation',
|
||||
],
|
||||
serverExternalPackages: ['@hyperdx/node-opentelemetry'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default withMDX(config);
|
||||
|
||||
@@ -1,56 +1,50 @@
|
||||
{
|
||||
"name": "@openpanel/public",
|
||||
"version": "0.1.0",
|
||||
"name": "public",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "rm -rf .next && pnpm with-env next dev",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"with-env": "dotenv -e ../../.env -c --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||
"@number-flow/react": "^0.3.0",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/nextjs": "1.0.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@t3-oss/env-nextjs": "^0.7.0",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/sdk-info": "workspace:^",
|
||||
"@openstatus/react": "^0.0.3",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"embla-carousel-autoplay": "8.0.0-rc22",
|
||||
"embla-carousel-react": "8.0.0-rc22",
|
||||
"lucide-react": "^0.323.0",
|
||||
"next": "~14.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-type-animation": "^3.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotted-map": "^2.2.3",
|
||||
"framer-motion": "^11.0.28",
|
||||
"fumadocs-core": "14.1.1",
|
||||
"fumadocs-mdx": "11.1.1",
|
||||
"fumadocs-ui": "14.1.1",
|
||||
"geist": "^1.3.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "15.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.9",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.2.2"
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "22.8.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const config = {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
BIN
apps/public/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
apps/public/public/article-example.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/public/public/avatar-2.jpg
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
apps/public/public/avatar-3.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
apps/public/public/avatar.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
apps/public/public/content/cover-alternatives.jpg
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
apps/public/public/content/cover-best-web-analytics.jpg
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
apps/public/public/content/cover-default.jpg
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
apps/public/public/content/cover-mixpanel.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
apps/public/public/content/screenshot-realtime.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |