feat: new public website

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-02 09:17:49 +01:00
parent e2536774b0
commit ac4429d6d9
206 changed files with 18415 additions and 12433 deletions

View File

@@ -0,0 +1,82 @@
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { SectionHeader } from '@/components/section';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { 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: Promise<{ pages: string[] }>;
}): Promise<Metadata> {
const { pages } = await params;
const page = await pageSource.getPage(pages);
if (!page) {
return {
title: 'Page Not Found',
};
}
return getPageMetadata({
title: page.data.title,
url: url(page.url),
description: page.data.description,
image: getOgImageUrl(page.url),
});
}
export default async function Page({
params,
}: {
params: Promise<{ 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': 'WebPage',
name: page.data.title,
description: page.data.description,
url: url(page.url),
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
};
return (
<div>
<Script
id="page-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<SectionHeader
as="h1"
title={page.data.title}
description={page.data.description}
/>
</HeroContainer>
<article className="container col prose">
<Body />
</article>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
import { ArticleCard } from '@/components/article-card';
import { FeatureCardContainer } from '@/components/feature-card';
import { GetStartedButton } from '@/components/get-started-button';
import { Logo } from '@/components/logo';
import { SectionHeader } from '@/components/section';
import { Toc } from '@/components/toc';
import { url, getAuthor } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { articleSource } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { ArrowLeftIcon } from 'lucide-react';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import Script from 'next/script';
export async function generateStaticParams() {
const articles = await articleSource.getPages();
return articles.map((article) => {
// Extract slug from URL (e.g., '/articles/my-article' -> 'my-article')
const slug = article.url.replace(/^\/articles\//, '').replace(/\/$/, '');
return { articleSlug: slug };
});
}
export async function generateMetadata({
params,
}: {
params: Promise<{ articleSlug: string }>;
}): Promise<Metadata> {
const { articleSlug } = await params;
const article = await articleSource.getPage([articleSlug]);
const author = getAuthor(article?.data.team);
if (!article) {
return {
title: 'Article Not Found',
};
}
return getPageMetadata({
title: article.data.title,
description: article.data.description,
url: url(article.url),
image: getOgImageUrl(article.url),
});
}
export default async function Page({
params,
}: {
params: Promise<{ articleSlug: string }>;
}) {
const { articleSlug } = await params;
const article = await articleSource.getPage([articleSlug]);
const Body = article?.data.body;
const author = getAuthor(article?.data.team);
const goBackUrl = '/articles';
const relatedArticles = (await articleSource.getPages())
.filter(
(item) =>
item.data.tag === article?.data.tag && item.url !== article?.url,
)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
if (!Body) {
return notFound();
}
// Create the JSON-LD data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article?.data.title,
datePublished: article?.data.date.toISOString(),
author: {
'@type': 'Person',
name: author.name,
},
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url(article.url),
},
image: {
'@type': 'ImageObject',
url: url(article.data.cover),
},
};
return (
<div>
<HeroContainer>
<div className="col">
<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>
<SectionHeader
as="h1"
title={article?.data.title}
description={article?.data.description}
/>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
{author.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
alt={author.name}
width={48}
height={48}
/>
) : (
<Logo className="w-6 h-6 fill-white" />
)}
</div>
<div className="col">
<p className="font-medium">{author.name}</p>
<p className="text-muted-foreground text-sm">
{article?.data.date.toLocaleDateString()}
</p>
</div>
</div>
</div>
</HeroContainer>
<Script
strategy="beforeInteractive"
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="container max-w-5xl col">
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
<div className="min-w-0">
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
<Body components={getMDXComponents()} />
</div>
</div>
<aside className="pl-12 pb-12 gap-8 col">
<Toc toc={article?.data.toc} />
<FeatureCardContainer className="gap-2">
<span className="text-lg font-semibold">Try OpenPanel</span>
<p className="text-muted-foreground text-sm mb-4">
Give it a spin for free. No credit card required.
</p>
<GetStartedButton />
</FeatureCardContainer>
</aside>
</div>
{relatedArticles.length > 0 && (
<div className="my-16">
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{relatedArticles.map((item) => (
<ArticleCard
key={item.url}
url={item.url}
title={item.data.title}
tag={item.data.tag}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</div>
</div>
)}
</article>
<Testimonials />
<CtaBanner />
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
import { ArticleCard } from '@/components/article-card';
import { Section, SectionHeader } from '@/components/section';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { articleSource } from '@/lib/source';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
export const metadata: Metadata = getPageMetadata({
title: 'Articles',
description:
'Read our latest articles and stay up to date with the latest news and updates.',
url: url('/articles'),
image: getOgImageUrl('/articles'),
});
export default async function Page() {
const articles = (await articleSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
return (
<div>
<HeroContainer className="-mb-32">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title="Articles"
description="Read our latest articles and stay up to date with the latest news and updates."
/>
</HeroContainer>
<Section className="container grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
{articles.map((item) => (
<ArticleCard
key={item.url}
url={item.url}
title={item.data.title}
tag={item.data.tag}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</Section>
<Testimonials />
<CtaBanner />
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { Section } from '@/components/section';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon } from 'lucide-react';
import Link from 'next/link';
interface BenefitsSectionProps {
label?: string;
title: string;
description: string;
cta?: {
label: string;
href: string;
};
benefits: string[];
className?: string;
}
export function BenefitsSection({
label,
title,
description,
cta,
benefits,
className,
}: BenefitsSectionProps) {
return (
<Section className={cn('container', className)}>
<div className="max-w-3xl col gap-6">
{label && (
<p className="text-sm italic text-primary font-medium">{label}</p>
)}
<h2 className="text-4xl md:text-5xl font-semibold leading-tight">
{title}
</h2>
<p className="text-lg text-muted-foreground">{description}</p>
{cta && (
<Button size="lg" asChild className="w-fit">
<Link href={cta.href}>{cta.label}</Link>
</Button>
)}
<div className="col gap-4 mt-4">
{benefits.map((benefit) => (
<div key={benefit} className="row gap-3 items-start">
<CheckIcon className="size-5 text-green-500 shrink-0 mt-0.5" />
<p className="text-muted-foreground">{benefit}</p>
</div>
))}
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,52 @@
import { FaqItem, Faqs } from '@/components/faq';
import { Section, SectionHeader } from '@/components/section';
import { CompareFaqs } from '@/lib/compare';
import Script from 'next/script';
import { url } from '@/lib/layout.shared';
interface CompareFaqProps {
faqs: CompareFaqs;
pageUrl: string;
}
export function CompareFaq({ faqs, pageUrl }: CompareFaqProps) {
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.items.map((q) => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: {
'@type': 'Answer',
text: q.answer,
},
})),
};
return (
<Section className="container">
<Script
strategy="beforeInteractive"
id="compare-faq-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader
className="mb-16"
title={faqs.title}
description={faqs.intro}
variant="sm"
/>
<Faqs>
{faqs.items.map((faq) => (
<FaqItem key={faq.question} question={faq.question}>
{faq.answer}
</FaqItem>
))}
</Faqs>
</div>
</Section>
);
}

View File

@@ -0,0 +1,52 @@
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks';
import { SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import type { CompareHero as CompareHeroData } from '@/lib/compare';
import { CheckCircle2Icon } from 'lucide-react';
import Link from 'next/link';
import { CompareToc } from './compare-toc';
interface CompareHeroProps {
hero: CompareHeroData;
tocItems?: Array<{ id: string; label: string }>;
}
export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
return (
<HeroContainer divider={false} className="-mb-32">
<div
className={
tocItems.length > 0
? 'grid md:grid-cols-[1fr_auto] gap-8 items-start'
: 'col gap-6'
}
>
<div className="col gap-6">
<SectionHeader
as="h1"
className="flex-1"
title={hero.heading}
description={hero.subheading}
variant="sm"
/>
<div className="row gap-4">
<GetStartedButton />
<Button size="lg" variant="outline" asChild>
<Link href={'https://demo.openpanel.dev'}>See live demo</Link>
</Button>
</div>
<Perks
className="flex gap-4 flex-wrap"
perks={hero.badges.map((badge) => ({
text: badge,
icon: CheckCircle2Icon,
}))}
/>
</div>
{tocItems.length > 0 && <CompareToc items={tocItems} />}
</div>
</HeroContainer>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { FeatureCardContainer } from '@/components/feature-card';
import { cn } from '@/lib/utils';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface TocItem {
id: string;
label: string;
}
interface CompareTocProps {
items: TocItem[];
className?: string;
}
export function CompareToc({ items, className }: CompareTocProps) {
const pathname = usePathname();
return (
<FeatureCardContainer
className={cn(
'hidden md:block sticky top-24 h-fit w-64 shrink-0',
'col gap-3 p-4 rounded-xl border bg-background/50 backdrop-blur-sm',
className,
)}
>
<nav className="col gap-1">
{items.map((item) => (
<Link
key={item.id}
href={`${pathname}#${item.id}`}
className="group/toc relative flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 py-1 min-h-6"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const offset = document.getElementById(`${item.id}`)?.offsetTop;
if (offset) {
window.scrollTo({
top: offset - 100,
behavior: 'smooth',
});
}
}}
>
<div className="absolute left-0 flex items-center w-0 overflow-hidden transition-all duration-300 ease-out group-hover/toc:w-5">
<ArrowRightIcon className="size-3 shrink-0 -translate-x-full group-hover/toc:translate-x-0 transition-transform duration-300 ease-out delay-75" />
</div>
<span className="transition-transform duration-300 ease-out group-hover/toc:translate-x-5">
{item.label}
</span>
</Link>
))}
</nav>
</FeatureCardContainer>
);
}

View File

@@ -0,0 +1,109 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareHighlights, CompareFeatureComparison } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ComparisonTableProps {
highlights: CompareHighlights;
featureComparison: CompareFeatureComparison;
competitorName: string;
}
function renderValue(value: boolean | string) {
if (typeof value === 'boolean') {
return value ? (
<CheckIcon className="size-5 text-green-500" />
) : (
<XIcon className="size-5 text-muted-foreground" />
);
}
// Check for common yes/no patterns
const lower = value.toLowerCase().trim();
if (lower === 'yes' || lower === 'true' || lower.includes('✓')) {
return <CheckIcon className="size-5 text-green-500" />;
}
if (lower === 'no' || lower === 'false' || lower.includes('✗')) {
return <XIcon className="size-5 text-muted-foreground" />;
}
return <span className="text-sm">{value}</span>;
}
export function ComparisonTable({
highlights,
featureComparison,
competitorName,
}: ComparisonTableProps) {
// Flatten feature groups into rows
const featureRows = featureComparison.groups.flatMap((group) =>
group.features.map((feature) => ({
feature: feature.name,
openpanel: feature.openpanel,
competitor: feature.competitor,
notes: feature.notes,
})),
);
const allRows = [
...highlights.items.map((h) => ({
feature: h.label,
openpanel: h.openpanel,
competitor: h.competitor,
notes: null,
})),
...featureRows,
];
return (
<Section className="container">
<SectionHeader
title={highlights.title}
description={highlights.intro}
variant="sm"
/>
<div className="mt-12 border rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left p-4 font-semibold">Feature</th>
<th className="text-left p-4 font-semibold">OpenPanel</th>
<th className="text-left p-4 font-semibold">{competitorName}</th>
</tr>
</thead>
<tbody>
{allRows.map((row, index) => (
<tr
key={row.feature}
className={cn(
'border-b last:border-b-0',
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
)}
>
<td className="p-4 font-medium">{row.feature}</td>
<td className="p-4">
<div className="row gap-2 items-center">
{renderValue(row.openpanel)}
</div>
</td>
<td className="p-4">
<div className="col gap-1">
<div className="row gap-2 items-center text-muted-foreground">
{renderValue(row.competitor)}
</div>
{row.notes && (
<span className="text-xs text-muted-foreground/70 mt-1">
{row.notes}
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,69 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareFeatureGroup } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
interface FeatureComparisonProps {
featureGroups: CompareFeatureGroup[];
}
function renderFeatureValue(value: boolean | string) {
if (typeof value === 'boolean') {
return value ? (
<CheckIcon className="size-5 text-green-500" />
) : (
<XIcon className="size-5 text-red-500" />
);
}
return <span className="text-sm text-muted-foreground">{value}</span>;
}
export function FeatureComparison({ featureGroups }: FeatureComparisonProps) {
return (
<Section className="container">
<SectionHeader
title="Feature comparison"
description="Detailed breakdown of capabilities"
align="center"
/>
<div className="mt-12 col gap-4">
{featureGroups.map((group) => (
<div key={group.group} className="border rounded-3xl overflow-hidden">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value={group.group} className="border-0">
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<h3 className="text-lg font-semibold">{group.group}</h3>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6">
<div className="col gap-4">
{group.features.map((feature) => (
<div
key={feature.name}
className="grid md:grid-cols-3 gap-4 py-3 border-b last:border-b-0"
>
<div className="font-medium text-sm">{feature.name}</div>
<div className="row gap-2 items-center">
{renderFeatureValue(feature.openpanel)}
</div>
<div className="row gap-2 items-center text-muted-foreground">
{renderFeatureValue(feature.competitor)}
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,63 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareFeatureComparison } from '@/lib/compare';
import {
HeartIcon,
MessageSquareIcon,
RefreshCwIcon,
SparklesIcon,
LayoutIcon,
BellIcon,
BrainIcon,
LockIcon,
} from 'lucide-react';
interface FeaturesShowcaseProps {
featureComparison: CompareFeatureComparison;
}
const featureIcons = [
HeartIcon,
MessageSquareIcon,
RefreshCwIcon,
SparklesIcon,
LayoutIcon,
BellIcon,
BrainIcon,
LockIcon,
];
export function FeaturesShowcase({ featureComparison }: FeaturesShowcaseProps) {
// Get all features that OpenPanel has (true or string values)
const openpanelFeatures = featureComparison.groups
.flatMap((group) => group.features)
.filter(
(f) =>
f.openpanel === true ||
(typeof f.openpanel === 'string' && f.openpanel.toLowerCase() !== 'no'),
)
.slice(0, 8);
return (
<Section className="container">
<SectionHeader
title={featureComparison.title}
description={featureComparison.intro}
variant="sm"
/>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
{openpanelFeatures.map((feature, index) => {
const Icon = featureIcons[index] || SparklesIcon;
return (
<div key={feature.name} className="col gap-3">
<div className="size-10 rounded-lg bg-primary/10 center-center">
<Icon className="size-5 text-primary" />
</div>
<h3 className="font-semibold text-sm">{feature.name}</h3>
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,57 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareHighlight } from '@/lib/compare';
import { CheckIcon, XIcon, MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface HighlightsGridProps {
highlights: CompareHighlight[];
}
function getIcon(value: string) {
const lower = value.toLowerCase();
if (lower === 'true' || lower === 'yes' || lower.includes('✓')) {
return <CheckIcon className="size-5 text-green-500" />;
}
if (lower === 'false' || lower === 'no' || lower.includes('✗')) {
return <XIcon className="size-5 text-red-500" />;
}
return <MinusIcon className="size-5 text-muted-foreground" />;
}
export function HighlightsGrid({ highlights }: HighlightsGridProps) {
return (
<Section className="container">
<SectionHeader
title="Key differences"
description="See how OpenPanel compares at a glance"
align="center"
/>
<div className="mt-12 border rounded-3xl overflow-hidden">
<div className="divide-y divide-border">
{highlights.map((highlight, index) => (
<div
key={highlight.label}
className={cn(
'grid md:grid-cols-3 gap-4 p-6',
index % 2 === 0 ? 'bg-muted/30' : 'bg-background'
)}
>
<div className="font-semibold text-sm md:text-base">
{highlight.label}
</div>
<div className="row gap-3 items-center">
{getIcon(highlight.openpanel)}
<span className="text-sm">{highlight.openpanel}</span>
</div>
<div className="row gap-3 items-center text-muted-foreground">
{getIcon(highlight.competitor)}
<span className="text-sm">{highlight.competitor}</span>
</div>
</div>
))}
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,82 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareMigration } from '@/lib/compare';
import { CheckIcon, ClockIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface MigrationSectionProps {
migration: CompareMigration;
}
export function MigrationSection({ migration }: MigrationSectionProps) {
return (
<Section className="container">
<SectionHeader
title={migration.title}
description={migration.intro}
variant="sm"
/>
{/* Difficulty and time */}
<div className="row gap-6 mt-8">
<div className="col gap-2">
<div className="row gap-2 items-center text-sm text-muted-foreground">
<ClockIcon className="size-4" />
<span className="font-medium">Difficulty:</span>
<span className="capitalize">{migration.difficulty}</span>
</div>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm text-muted-foreground">
<ClockIcon className="size-4" />
<span className="font-medium">Estimated time:</span>
<span>{migration.estimated_time}</span>
</div>
</div>
</div>
{/* Steps */}
<div className="col gap-4 mt-12">
{migration.steps.map((step, index) => (
<div key={step.title} className="col gap-2 p-6 border rounded-2xl">
<div className="row gap-3 items-start">
<div className="size-8 rounded-full bg-primary/10 center-center shrink-0 font-semibold text-sm">
{index + 1}
</div>
<div className="col gap-1 flex-1">
<h3 className="font-semibold">{step.title}</h3>
<p className="text-sm text-muted-foreground">{step.description}</p>
</div>
</div>
</div>
))}
</div>
{/* SDK Compatibility */}
<div className="mt-12 p-6 border rounded-2xl bg-muted/30">
<div className="col gap-4">
<div className="row gap-2 items-center">
<CheckIcon className="size-5 text-green-500" />
<h3 className="font-semibold">SDK Compatibility</h3>
</div>
<p className="text-sm text-muted-foreground">{migration.sdk_compatibility.notes}</p>
</div>
</div>
{/* Historical Data */}
<div className="mt-6 p-6 border rounded-2xl bg-muted/30">
<div className="col gap-4">
<div className="row gap-2 items-center">
{migration.historical_data.can_import ? (
<CheckIcon className="size-5 text-green-500" />
) : (
<CheckIcon className="size-5 text-muted-foreground" />
)}
<h3 className="font-semibold">Historical Data Import</h3>
</div>
<p className="text-sm text-muted-foreground">{migration.historical_data.notes}</p>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,86 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import type { ComparePricing } from '@/lib/compare';
import { cn } from '@/lib/utils';
import { DollarSignIcon } from 'lucide-react';
interface PricingComparisonRow {
feature: string;
openpanel: string;
competitor: string;
}
interface PricingComparisonProps {
pricing: ComparePricing;
pricingTable?: PricingComparisonRow[];
competitorName: string;
}
export function PricingComparison({
pricing,
pricingTable = [],
competitorName,
}: PricingComparisonProps) {
return (
<Section className="container">
<SectionHeader
title={pricing.title}
description={pricing.intro}
align="center"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<FeatureCard
title="OpenPanel"
description={pricing.openpanel.model}
icon={DollarSignIcon}
className="border-green-500/20 bg-green-500/5"
>
<div className="col gap-3 mt-4">
<p className="text-sm text-muted-foreground">
{pricing.openpanel.description}
</p>
</div>
</FeatureCard>
<FeatureCard
title={competitorName}
description={pricing.competitor.model}
icon={DollarSignIcon}
>
<div className="col gap-3 mt-4">
<p className="text-sm text-muted-foreground">
{pricing.competitor.description}
</p>
{pricing.competitor.free_tier && (
<p className="text-xs text-muted-foreground">
Free tier: {pricing.competitor.free_tier}
</p>
)}
</div>
</FeatureCard>
</div>
{pricingTable.length > 0 && (
<div className="mt-12 border rounded-3xl overflow-hidden">
<div className="divide-y divide-border">
{pricingTable.map((row, index) => (
<div
key={row.feature}
className={cn(
'grid md:grid-cols-3 gap-4 p-6',
index % 2 === 0 ? 'bg-muted/30' : 'bg-background',
)}
>
<div className="font-semibold text-sm md:text-base">
{row.feature}
</div>
<div className="text-sm">{row.openpanel}</div>
<div className="text-sm text-muted-foreground">
{row.competitor}
</div>
</div>
))}
</div>
</div>
)}
</Section>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { Section, SectionHeader } from '@/components/section';
import type { ComparePricing } from '@/lib/compare';
import { ArrowRightIcon, CheckIcon } from 'lucide-react';
import { motion } from 'framer-motion';
import Link from 'next/link';
interface PricingSectionProps {
pricing: ComparePricing;
competitorName: string;
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
},
},
};
const cardVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
},
};
function parseDescription(description: string) {
// Split by periods followed by space and capital letter, or by newlines
const sentences = description
.split(/(?<=\.)\s+(?=[A-Z])/)
.filter((s) => s.trim().length > 0);
return sentences;
}
export function PricingSection({
pricing,
competitorName,
}: PricingSectionProps) {
const openpanelPoints = parseDescription(pricing.openpanel.description);
const competitorPoints = parseDescription(pricing.competitor.description);
return (
<Section className="container">
<SectionHeader
title={pricing.title}
description={pricing.intro}
variant="sm"
/>
{/* Pricing comparison */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-100px' }}
variants={containerVariants}
className="grid md:grid-cols-2 gap-6 mt-12"
>
{/* OpenPanel Card */}
<motion.div
variants={cardVariants}
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-emerald-500/30 transition-all duration-300"
>
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5 light:from-emerald-800/10 light:via-transparent light:to-green-900/10 group-hover:opacity-150 transition-opacity duration-500" />
<div className="col gap-3 relative z-10">
<div className="col gap-2">
<h3 className="text-xl font-semibold">OpenPanel</h3>
<p className="text-sm text-muted-foreground font-medium">
{pricing.openpanel.model}
</p>
</div>
<div className="col gap-2 mt-2">
{openpanelPoints.map((point, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
>
<CheckIcon className="size-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5 group-hover/item:scale-110 transition-transform duration-300" />
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
{point.trim()}
</p>
</motion.div>
))}
</div>
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="col gap-2 mt-2"
>
<div className="row gap-2 items-center p-3 rounded-lg bg-muted/30 border border-emerald-500/10">
<span className="text-xs font-medium text-muted-foreground">
Free tier:
</span>
<span className="text-xs text-muted-foreground">
Self-hosting (unlimited events)
</span>
</div>
<div className="row gap-2 items-center p-3 rounded-lg bg-muted/30 border border-emerald-500/10">
<span className="text-xs font-medium text-muted-foreground">
Free trial:
</span>
<span className="text-xs text-muted-foreground">
30 days
</span>
</div>
</motion.div>
</div>
</motion.div>
{/* Competitor Card */}
<motion.div
variants={cardVariants}
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-orange-500/30 transition-all duration-300"
>
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5 light:from-orange-800/10 light:via-transparent light:to-amber-900/10 group-hover:opacity-150 transition-opacity duration-500" />
<div className="col gap-3 relative z-10">
<div className="col gap-2">
<h3 className="text-xl font-semibold">{competitorName}</h3>
<p className="text-sm text-muted-foreground font-medium">
{pricing.competitor.model}
</p>
</div>
<div className="col gap-2 mt-2">
{competitorPoints.map((point, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
>
<CheckIcon className="size-4 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5 group-hover/item:scale-110 transition-transform duration-300" />
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
{point.trim()}
</p>
</motion.div>
))}
</div>
{pricing.competitor.free_tier && (
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="row gap-2 items-center mt-2 p-3 rounded-lg bg-muted/30 border border-orange-500/10"
>
<span className="text-xs font-medium text-muted-foreground">
Free tier:
</span>
<span className="text-xs text-muted-foreground">
{pricing.competitor.free_tier}
</span>
</motion.div>
)}
{pricing.competitor.pricing_url && (
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.4 }}
>
<Link
href={pricing.competitor.pricing_url}
target="_blank"
rel="noopener noreferrer"
className="row gap-2 items-center text-xs text-primary hover:text-primary/80 transition-colors duration-300 mt-2 group/link"
>
<span>View pricing</span>
<ArrowRightIcon className="size-3 group-hover/link:translate-x-1 transition-transform duration-300" />
</Link>
</motion.div>
)}
</div>
</motion.div>
</motion.div>
</Section>
);
}

View File

@@ -0,0 +1,38 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareSummary } from '@/lib/compare';
import { UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon } from 'lucide-react';
interface ProblemSectionProps {
summary: CompareSummary;
competitorName: string;
}
const problemIcons = [UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon];
export function ProblemSection({ summary, competitorName }: ProblemSectionProps) {
const problems = summary.best_for_competitor.slice(0, 4);
return (
<Section className="container">
<SectionHeader
title={summary.title}
description={summary.intro}
variant="sm"
/>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
{problems.map((problem, index) => {
const Icon = problemIcons[index] || UsersIcon;
return (
<div key={problem} className="col gap-3 text-center">
<div className="size-12 rounded-full bg-muted center-center mx-auto">
<Icon className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{problem}</p>
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,52 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { CompareSummary } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
interface SummaryComparisonProps {
summary: CompareSummary;
competitorName: string;
}
export function SummaryComparison({ summary, competitorName }: SummaryComparisonProps) {
return (
<Section className="container">
<SectionHeader
title="Quick comparison"
description={summary.one_liner}
align="center"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<FeatureCard
title="Best for OpenPanel"
description=""
className="border-green-500/20 bg-green-500/5"
>
<ul className="col gap-3 mt-4">
{summary.best_for_openpanel.map((item) => (
<li key={item} className="row gap-2 items-start text-sm">
<CheckIcon className="size-4 shrink-0 mt-0.5 text-green-500" />
<span>{item}</span>
</li>
))}
</ul>
</FeatureCard>
<FeatureCard
title={`Best for ${competitorName}`}
description=""
className="border-muted"
>
<ul className="col gap-3 mt-4">
{summary.best_for_competitor.map((item) => (
<li key={item} className="row gap-2 items-start text-sm">
<XIcon className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
</FeatureCard>
</div>
</Section>
);
}

View File

@@ -0,0 +1,77 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareTechnicalComparison } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface TechnicalComparisonProps {
technical: CompareTechnicalComparison;
competitorName: string;
}
function renderValue(value: string | string[]) {
if (Array.isArray(value)) {
return (
<ul className="col gap-1">
{value.map((item, idx) => (
<li key={idx} className="text-sm">
{item}
</li>
))}
</ul>
);
}
return <span className="text-sm">{value}</span>;
}
export function TechnicalComparison({
technical,
competitorName,
}: TechnicalComparisonProps) {
return (
<Section className="container">
<SectionHeader
title={technical.title}
description={technical.intro}
variant="sm"
/>
<div className="mt-12 border rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left p-4 font-semibold">Feature</th>
<th className="text-left p-4 font-semibold">OpenPanel</th>
<th className="text-left p-4 font-semibold">{competitorName}</th>
</tr>
</thead>
<tbody>
{technical.items.map((item, index) => (
<tr
key={item.label}
className={cn(
'border-b last:border-b-0',
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
)}
>
<td className="p-4 font-medium">{item.label}</td>
<td className="p-4">{renderValue(item.openpanel)}</td>
<td className="p-4 text-muted-foreground">
<div className="col gap-1">
{renderValue(item.competitor)}
{item.notes && (
<span className="text-xs text-muted-foreground/70 mt-1">
{item.notes}
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,110 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { CompareTrustCompliance } from '@/lib/compare';
import { ShieldIcon, MapPinIcon, ServerIcon } from 'lucide-react';
import { CheckIcon, XIcon } from 'lucide-react';
interface TrustComplianceProps {
trust: CompareTrustCompliance;
}
export function TrustCompliance({ trust }: TrustComplianceProps) {
return (
<Section className="container">
<SectionHeader
title={trust.title}
description={trust.intro}
variant="sm"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<FeatureCard
title="OpenPanel"
description=""
className="border-green-500/20 bg-green-500/5"
>
<div className="col gap-4 mt-4">
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<ShieldIcon className="size-4" />
<span className="font-medium">Data Processing</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{trust.openpanel.data_processing}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<MapPinIcon className="size-4" />
<span className="font-medium">Data Location</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{trust.openpanel.data_location}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<ServerIcon className="size-4" />
<span className="font-medium">Self-Hosting</span>
</div>
<div className="row gap-2 items-center text-sm ml-6">
{trust.openpanel.self_hosting ? (
<>
<CheckIcon className="size-4 text-green-500" />
<span className="text-muted-foreground">Available</span>
</>
) : (
<>
<XIcon className="size-4 text-red-500" />
<span className="text-muted-foreground">Not available</span>
</>
)}
</div>
</div>
</div>
</FeatureCard>
<FeatureCard title="Competitor" description="">
<div className="col gap-4 mt-4">
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<ShieldIcon className="size-4" />
<span className="font-medium">Data Processing</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{trust.competitor.data_processing}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<MapPinIcon className="size-4" />
<span className="font-medium">Data Location</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{trust.competitor.data_location}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<ServerIcon className="size-4" />
<span className="font-medium">Self-Hosting</span>
</div>
<div className="row gap-2 items-center text-sm ml-6">
{trust.competitor.self_hosting ? (
<>
<CheckIcon className="size-4 text-green-500" />
<span className="text-muted-foreground">Available</span>
</>
) : (
<>
<XIcon className="size-4 text-red-500" />
<span className="text-muted-foreground">Not available</span>
</>
)}
</div>
</div>
</div>
</FeatureCard>
</div>
</Section>
);
}

View File

@@ -0,0 +1,26 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareUseCases } from '@/lib/compare';
interface UseCasesProps {
useCases: CompareUseCases;
}
export function UseCases({ useCases }: UseCasesProps) {
return (
<Section className="container">
<SectionHeader
title={useCases.title}
description={useCases.intro}
variant="sm"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
{useCases.items.map((useCase) => (
<div key={useCase.title} className="col gap-2 p-6 border rounded-2xl">
<h3 className="font-semibold">{useCase.title}</h3>
<p className="text-sm text-muted-foreground">{useCase.description}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { Section } from '@/components/section';
import type { CompareSummary } from '@/lib/compare';
import { motion } from 'framer-motion';
interface WhoShouldChooseProps {
summary: CompareSummary;
competitorName: string;
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
},
},
};
const cardVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
},
};
export function WhoShouldChoose({
summary,
competitorName,
}: WhoShouldChooseProps) {
const openpanelItems = summary.best_for_openpanel.slice(0, 3);
const competitorItems = summary.best_for_competitor.slice(0, 3);
return (
<Section className="container">
<div className="col gap-4 mb-12">
<h2 className="text-3xl md:text-4xl font-semibold">{summary.title}</h2>
<p className="text-muted-foreground max-w-3xl">{summary.intro}</p>
</div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-100px' }}
variants={containerVariants}
className="grid md:grid-cols-2 gap-6"
>
{/* OpenPanel Card */}
<motion.div
variants={cardVariants}
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-emerald-500/30 transition-all duration-300"
>
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5 light:from-emerald-800/10 light:via-transparent light:to-green-900/10 group-hover:opacity-150 transition-opacity duration-500" />
<div className="col gap-3 relative z-10">
<div className="col gap-2">
<h3 className="text-xl font-semibold">Choose OpenPanel if...</h3>
</div>
<div className="col gap-2 mt-2">
{openpanelItems.map((item, index) => (
<motion.div
key={item}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
>
<div className="size-4 rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0 mt-0.5 flex items-center justify-center group-hover/item:scale-110 transition-transform duration-300">
<span className="text-[10px] font-bold text-white">
{index + 1}
</span>
</div>
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
{item}
</p>
</motion.div>
))}
</div>
</div>
</motion.div>
{/* Competitor Card */}
<motion.div
variants={cardVariants}
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-orange-500/30 transition-all duration-300"
>
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5 light:from-orange-800/10 light:via-transparent light:to-amber-900/10 group-hover:opacity-150 transition-opacity duration-500" />
<div className="col gap-3 relative z-10">
<div className="col gap-2">
<h3 className="text-xl font-semibold">
Choose {competitorName} if...
</h3>
</div>
<div className="col gap-2 mt-2">
{competitorItems.map((item, index) => (
<motion.div
key={item}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
>
<div className="size-4 rounded-full bg-orange-600 dark:bg-orange-400 shrink-0 mt-0.5 flex items-center justify-center group-hover/item:scale-110 transition-transform duration-300">
<span className="text-[10px] font-bold text-white">
{index + 1}
</span>
</div>
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
{item}
</p>
</motion.div>
))}
</div>
</div>
</motion.div>
</motion.div>
</Section>
);
}

View File

@@ -0,0 +1,55 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareSummary } from '@/lib/compare';
import {
UsersIcon,
SparklesIcon,
SearchIcon,
MoonIcon,
ShieldIcon,
ServerIcon,
ZapIcon,
CheckCircleIcon,
} from 'lucide-react';
interface WhySwitchProps {
summary: CompareSummary;
}
const benefitIcons = [
UsersIcon,
SparklesIcon,
SearchIcon,
MoonIcon,
ShieldIcon,
ServerIcon,
ZapIcon,
CheckCircleIcon,
];
export function WhySwitch({ summary }: WhySwitchProps) {
const benefits = summary.best_for_openpanel.slice(0, 8);
return (
<Section className="container">
<SectionHeader
title={summary.title}
description={summary.intro}
variant="sm"
/>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mt-12">
{benefits.map((benefit, index) => {
const Icon = benefitIcons[index] || CheckCircleIcon;
return (
<div key={benefit} className="col gap-3">
<div className="size-10 rounded-lg bg-primary/10 center-center">
<Icon className="size-5 text-primary" />
</div>
<h3 className="font-semibold text-sm">{benefit}</h3>
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,224 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { WindowImage } from '@/components/window-image';
import {
type CompareData,
getAllCompareSlugs,
getCompareData,
} from '@/lib/compare';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { BenefitsSection } from './_components/benefits-section';
import { CompareFaq } from './_components/compare-faq';
import { CompareHero } from './_components/compare-hero';
import { ComparisonTable } from './_components/comparison-table';
import { FeaturesShowcase } from './_components/features-showcase';
import { MigrationSection } from './_components/migration-section';
import { PricingSection } from './_components/pricing-section';
import { TechnicalComparison } from './_components/technical-comparison';
import { UseCases } from './_components/use-cases';
import { WhoShouldChoose } from './_components/who-should-choose';
export async function generateStaticParams() {
const slugs = await getAllCompareSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const data = await getCompareData(slug);
if (!data) {
return {
title: 'Comparison Not Found',
};
}
return getPageMetadata({
title: data.seo.title,
description: data.seo.description,
url: data.url,
image: getOgImageUrl(data.url),
});
}
export default async function ComparePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const data = await getCompareData(slug);
if (!data) {
return notFound();
}
const pageUrl = url(`/compare/${slug}`);
// Create JSON-LD schema
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: data.seo.title,
description: data.seo.description,
url: pageUrl,
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
};
// Build ToC items
const tocItems = [
{ id: 'who-should-choose', label: data.summary_comparison.title },
{ id: 'comparison', label: data.highlights.title },
{ id: 'features', label: data.feature_comparison.title },
...(data.technical_comparison
? [{ id: 'technical', label: data.technical_comparison.title }]
: []),
{ id: 'pricing', label: data.pricing.title },
...(data.migration
? [{ id: 'migration', label: data.migration.title }]
: []),
{ id: 'use-cases', label: data.use_cases.title },
...(data.benefits_section
? [{ id: 'benefits', label: data.benefits_section.title }]
: []),
{ id: 'faq', label: data.faqs.title },
];
return (
<div>
<Script
id="compare-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<CompareHero hero={data.hero} tocItems={tocItems} />
<div className="container my-16">
<WindowImage
srcDark="/screenshots/overview-dark.png"
srcLight="/screenshots/overview-light.png"
alt="OpenPanel Dashboard Overview"
caption="This is our web analytics dashboard, its an out-of-the-box experience so you can start understanding your traffic and engagement right away."
/>
</div>
<div id="who-should-choose">
<WhoShouldChoose
summary={data.summary_comparison}
competitorName={data.competitor.name}
/>
</div>
<div className="container my-16">
<WindowImage
srcDark="/screenshots/dashboard-dark.png"
srcLight="/screenshots/dashboard-light.png"
alt="OpenPanel Dashboard"
caption="Comprehensive analytics dashboard with real-time insights and customizable views."
/>
</div>
<div id="comparison">
<ComparisonTable
highlights={data.highlights}
featureComparison={data.feature_comparison}
competitorName={data.competitor.name}
/>
</div>
<div id="features">
<FeaturesShowcase featureComparison={data.feature_comparison} />
</div>
<div className="container my-16">
<WindowImage
srcDark="/screenshots/realtime-dark.png"
srcLight="/screenshots/realtime-light.png"
alt="OpenPanel Real-time Analytics"
caption="Track events in real-time as they happen with instant updates and live monitoring."
/>
</div>
{data.technical_comparison && (
<div id="technical">
<TechnicalComparison
technical={data.technical_comparison}
competitorName={data.competitor.name}
/>
</div>
)}
<div id="pricing">
<PricingSection
pricing={data.pricing}
competitorName={data.competitor.name}
/>
</div>
{data.migration && (
<div id="migration">
<MigrationSection migration={data.migration} />
</div>
)}
<div id="use-cases">
<UseCases useCases={data.use_cases} />
</div>
<div className="container my-16">
<WindowImage
srcDark="/screenshots/report-dark.png"
srcLight="/screenshots/report-light.png"
alt="OpenPanel Reports"
caption="Generate detailed reports and insights with customizable metrics and visualizations."
/>
</div>
{data.benefits_section && (
<div id="benefits">
<BenefitsSection
label={data.benefits_section.label}
title={data.benefits_section.title}
description={data.benefits_section.description}
cta={data.benefits_section.cta}
benefits={data.benefits_section.benefits}
/>
</div>
)}
<div className="container my-16">
<WindowImage
srcDark="/screenshots/profile-dark.png"
srcLight="/screenshots/profile-light.png"
alt="OpenPanel User Profiles"
caption="Deep dive into individual user profiles with complete event history and behavior tracking."
/>
</div>
<div id="faq">
<CompareFaq faqs={data.faqs} pageUrl={pageUrl} />
</div>
<CtaBanner
title={'Ready to make the switch?'}
description="Test OpenPanel free for 30 days, you'll not be charged anything unless you upgrade to a paid plan."
ctaText={data.ctas.primary.label}
ctaLink={data.ctas.primary.href}
/>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { FeatureCard, FeatureCardContainer } from '@/components/feature-card';
import { cn } from '@/lib/utils';
import { ArrowRightIcon } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
interface CompareCardProps {
name: string;
logo?: string;
description: string;
url: string;
}
export function CompareCard({
url,
name,
logo,
description,
}: CompareCardProps) {
return (
<Link href={url}>
<FeatureCardContainer>
<div className="row gap-3 items-center">
{logo && (
<div className="relative size-10 shrink-0 rounded-lg overflow-hidden border bg-background p-1.5">
<Image
src={logo}
alt={`${name} logo`}
width={40}
height={40}
className="object-contain w-full h-full"
/>
</div>
)}
<div className="col gap-1 flex-1 min-w-0">
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
{name}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
</div>
<ArrowRightIcon className="opacity-0 group-hover:opacity-100 size-5 shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all duration-300" />
</div>
</FeatureCardContainer>
</Link>
);
}

View File

@@ -0,0 +1,73 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { Section, SectionHeader } from '@/components/section';
import { WindowImage } from '@/components/window-image';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { compareSource } from '@/lib/source';
import type { Metadata } from 'next';
import { CompareHero } from './[slug]/_components/compare-hero';
import { CompareCard } from './_components/compare-card';
const title = 'Compare OpenPanel with alternatives';
const description =
'See detailed feature and pricing comparisons between OpenPanel and popular analytics tools. Honest breakdowns showing what each tool does well and where OpenPanel provides better value.';
export const metadata: Metadata = getPageMetadata({
title,
description,
url: url('/compare'),
image: getOgImageUrl('/compare'),
});
const heroData = {
heading: 'Compare OpenPanel with any alternative',
subheading:
'See detailed feature and pricing comparisons between OpenPanel and popular analytics tools. Honest breakdowns showing what each tool does well and where OpenPanel provides better value for growing teams.',
badges: ['30 days free trial', 'Unlimited users', '30-second setup'],
};
export default async function CompareIndexPage() {
const comparisons = compareSource.sort((a, b) =>
a.competitor.name.localeCompare(b.competitor.name),
);
return (
<div>
<CompareHero hero={heroData} />
<div className="container my-16">
<WindowImage
srcDark="/screenshots/overview-dark.png"
srcLight="/screenshots/overview-light.png"
alt="OpenPanel Dashboard Overview"
caption="This is our web analytics dashboard, its an out-of-the-box experience so you can start understanding your traffic and engagement right away."
/>
</div>
<Section className="container">
<SectionHeader
title="All product comparisons"
description="Browse our complete list of detailed comparisons. See how OpenPanel stacks up against each competitor on features, pricing, and value."
variant="sm"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-12">
{comparisons.map((comparison) => (
<CompareCard
key={comparison.slug}
url={comparison.url}
name={comparison.competitor.name}
description={comparison.competitor.short_description}
/>
))}
</div>
</Section>
<CtaBanner
title="Ready to get started?"
description="Join thousands of teams using OpenPanel for their analytics needs."
ctaText="Get Started Free"
ctaLink="/onboarding"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Footer } from '@/components/footer';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
export default function Layout({
children,
}: {
children: ReactNode;
}): React.ReactElement {
return (
<>
<Navbar />
<main>{children}</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,109 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { Faq } from '@/app/(home)/_sections/faq';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Pricing } from '@/app/(home)/_sections/pricing';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
import { Section, SectionHeader } from '@/components/section';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { formatEventsCount } from '@/lib/utils';
import { PRICING } from '@openpanel/payments/prices';
import type { Metadata } from 'next';
import Script from 'next/script';
const title = 'OpenPanel Cloud Pricing';
const description =
'Our pricing is as simple as it gets, choose how many events you want to track each month, everything else is unlimited, no tiers, no hidden costs.';
export const metadata: Metadata = getPageMetadata({
title,
description,
url: url('/pricing'),
image: getOgImageUrl('/pricing'),
});
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description: description,
url: url('/pricing'),
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
};
export default function SupporterPage() {
return (
<div>
<Script
id="pricing-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer className="-mb-32">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title={title}
description={description}
/>
</HeroContainer>
<Pricing />
<PricingTable />
<Testimonials />
<Faq />
<CtaBanner />
</div>
);
}
function PricingTable() {
return (
<Section className="container">
<SectionHeader
title="Full pricing table"
description="Here's the full pricing table for all plans. You can use the discount code to get a discount on your subscription."
/>
<div className="prose mt-8">
<table className="bg-card">
<thead>
<tr>
<th>Plan</th>
<th className="text-right">Monthly price</th>
<th className="text-right">Yearly price (2 months free)</th>
</tr>
</thead>
<tbody>
{PRICING.map((price) => (
<tr key={price.price}>
<td className="font-semibold">
{formatEventsCount(price.events)} events per month
</td>
<td className="text-right">
{Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price.price)}
</td>
<td className="text-right">
{Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price.price * 10)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
);
}

View File

@@ -0,0 +1,215 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { SupporterPerks } from 'components/sections/supporter-perks';
import {
ClockIcon,
GithubIcon,
InfinityIcon,
MessageSquareIcon,
RocketIcon,
SparklesIcon,
StarIcon,
ZapIcon,
} from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
export const metadata: Metadata = getPageMetadata({
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
url: url('/supporter'),
image: getOgImageUrl('/supporter'),
});
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
url: url('/supporter'),
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
};
export default function SupporterPage() {
return (
<div>
<Script
id="supporter-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<div className="col center-center flex-1">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title={
<>
Help us build
<br />
the future of open analytics
</>
}
description="Your support accelerates development, funds infrastructure, and helps us build features faster. Plus, you get exclusive perks and early access to everything we ship."
/>
<div className="col gap-4 justify-center items-center mt-8">
<Button size="lg" asChild>
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
Become a Supporter
<SparklesIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Starting at $20/month Cancel anytime
</p>
</div>
</div>
</HeroContainer>
<div className="container">
{/* Main Content with Sidebar */}
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
{/* Main Content */}
<div className="col gap-16">
{/* Why Support Section */}
<Section className="my-0">
<SectionHeader
title="Why your support matters"
description="We're not a big corporation just a small team passionate about building something useful for developers. OpenPanel started because we believed analytics tools shouldn't be complicated or locked behind expensive enterprise subscriptions."
/>
<div className="col gap-6 mt-8">
<p className="text-muted-foreground">
When you become a supporter, you're directly funding:
</p>
<div className="grid md:grid-cols-2 gap-4">
<FeatureCard
title="Active Development"
description="More time fixing bugs, adding features, and improving documentation"
icon={ZapIcon}
/>
<FeatureCard
title="Infrastructure"
description="Keeping servers running, CI/CD pipelines, and development tools"
icon={ZapIcon}
/>
<FeatureCard
title="Independence"
description="Staying focused on what matters: building a tool developers actually want"
icon={ZapIcon}
/>
</div>
<p className="text-muted-foreground">
No corporate speak, no fancy promises just honest work on
making OpenPanel better for everyone. Every contribution, no
matter the size, helps us stay independent and focused on what
matters.
</p>
</div>
</Section>
{/* What You Get Section */}
<Section className="my-0">
<SectionHeader
title="What you get as a supporter"
description="Exclusive perks and early access to everything we ship."
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<FeatureCard
title="Latest Docker Images"
description="Get bleeding-edge builds on every commit. Access new features weeks before public release."
icon={RocketIcon}
>
<Link
href="/docs/self-hosting/supporter-access-latest-docker-images"
className="text-sm text-primary hover:underline mt-2"
>
Learn more →
</Link>
</FeatureCard>
<FeatureCard
title="Prioritized Support"
description="Get help faster with priority support in our Discord community. Your questions get answered first."
icon={MessageSquareIcon}
/>
<FeatureCard
title="Feature Requests"
description="Your ideas and feature requests get prioritized in our roadmap. Shape the future of OpenPanel."
icon={SparklesIcon}
/>
<FeatureCard
title="Exclusive Discord Role"
description="Special badge and recognition in our community. Show your support with pride."
icon={StarIcon}
/>
</div>
</Section>
{/* Impact Section */}
<Section className="my-0">
<SectionHeader
title="Your impact"
description="Every dollar you contribute goes directly into development, infrastructure, and making OpenPanel better. Here's what your support enables:"
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<FeatureCard
title="100% Open Source"
description="Full transparency. Audit the code, contribute, fork it, or self-host without lock-in."
icon={GithubIcon}
/>
<FeatureCard
title="24/7 Active Development"
description="Continuous improvements and updates. Your support enables faster development cycles."
icon={ClockIcon}
/>
<FeatureCard
title="Self-Hostable"
description="Deploy OpenPanel anywhere - your server, your cloud, or locally. Full flexibility."
icon={InfinityIcon}
/>
</div>
</Section>
</div>
{/* Sidebar */}
<aside className="lg:block hidden">
<SupporterPerks />
</aside>
</div>
{/* Mobile Perks */}
<div className="lg:hidden mb-16">
<SupporterPerks />
</div>
<CtaBanner
title="Ready to support OpenPanel?"
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
ctaText="Become a Supporter"
ctaLink="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
/>
</div>
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
{/* <Testimonials />
<Faq /> */}
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import {
BarChart3Icon,
DollarSignIcon,
GlobeIcon,
ZapIcon,
} from 'lucide-react';
import { ProductAnalyticsIllustration } from './illustrations/product-analytics';
import { WebAnalyticsIllustration } from './illustrations/web-analytics';
const features = [
{
title: 'Revenue tracking',
description:
'Track revenue from your payments and get insights into your revenue sources.',
icon: DollarSignIcon,
},
{
title: 'Profiles & Sessions',
description:
'Track individual users and their complete journey across your platform.',
icon: GlobeIcon,
},
{
title: 'Event Tracking',
description:
'Capture every important interaction with flexible event tracking.',
icon: BarChart3Icon,
},
];
export function AnalyticsInsights() {
return (
<Section className="container">
<SectionHeader
label="ANALYTICS & INSIGHTS"
title="See the full picture of your users and product performance"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking."
className="mb-16"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<FeatureCard
variant="large"
title="Web Analytics"
description="Understand your website performance with privacy-first analytics and clear, actionable insights."
illustration={<WebAnalyticsIllustration />}
className="px-0 **:data-content:px-6"
/>
<FeatureCard
variant="large"
title="Product Analytics"
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends."
illustration={<ProductAnalyticsIllustration />}
className="px-0 **:data-content:px-6"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{features.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
/>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { FeatureCardContainer } from '@/components/feature-card';
import { MoreVerticalIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
// Sample data for the last 7 days
const data = [
{ day: 'Mon', visitors: 1200, revenue: 1250 },
{ day: 'Tue', visitors: 1450, revenue: 1890 },
{ day: 'Wed', visitors: 1320, revenue: 1520 },
{ day: 'Thu', visitors: 1580, revenue: 2100 },
{ day: 'Fri', visitors: 1420, revenue: 1750 },
{ day: 'Sat', visitors: 1180, revenue: 1100 },
{ day: 'Sun', visitors: 1250, revenue: 1380 },
];
// Custom tooltip component
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const visitors =
payload.find((p: any) => p.dataKey === 'visitors')?.value || 0;
const revenue =
payload.find((p: any) => p.dataKey === 'revenue')?.value || 0;
return (
<div className="bg-card border border-border rounded-lg p-3 shadow-lg min-w-[200px]">
<div className="text-sm font-semibold mb-2">{label}</div>
<div className="text-sm text-muted-foreground space-y-1 flex-1">
<div className="row gap-2 items-center flex-1">
<div className="h-6 bg-foreground w-1 rounded-full" />
<div className="font-medium row items-center gap-2 justify-between flex-1">
<span>Visitors</span> <span>{visitors.toLocaleString()}</span>
</div>
</div>
<div className="row gap-2 items-center flex-1">
<div className="h-6 bg-emerald-500 w-1 rounded-full" />
<div className="font-medium row items-center gap-2 justify-between flex-1">
<span>Revenue</span> <span>${revenue.toLocaleString()}</span>
</div>
</div>
</div>
</div>
);
}
return null;
};
export function CollaborationChart() {
const [activeIndex, setActiveIndex] = useState<number | null>(1); // Default to Tue (index 1)
// Calculate metrics from active point or default
const activeData = useMemo(() => {
return activeIndex !== null ? data[activeIndex] : data[1];
}, [activeIndex]);
const totalVisitors = activeData.visitors;
const totalRevenue = activeData.revenue;
return (
<FeatureCardContainer className="col gap-4 h-full">
{/* Header */}
<div className="row items-center justify-between">
<div>
<h3 className="font-semibold">Product page views</h3>
<p className="text-sm text-muted-foreground">Last 7 days</p>
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<MoreVerticalIcon className="size-4" />
</button>
</div>
{/* Chart */}
<div className="flex-1 min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
onMouseMove={(state) => {
if (state?.activeTooltipIndex !== undefined) {
setActiveIndex(state.activeTooltipIndex);
}
}}
onMouseLeave={() => setActiveIndex(null)}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
/>
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
hide
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
domain={[0, 2400]}
hide
/>
<Tooltip content={<CustomTooltip />} cursor={false} />
{/* Revenue bars */}
<Bar yAxisId="right" dataKey="revenue" radius={4}>
{data.map((entry, index) => (
<Cell
key={`cell-${entry.day}`}
className={
activeIndex === index
? 'fill-emerald-500' // Lighter green on hover
: 'fill-foreground/30' // Default green
}
style={{ transition: 'fill 0.2s ease' }}
/>
))}
</Bar>
<Line
yAxisId="left"
type="monotone"
dataKey="visitors"
strokeWidth={2}
stroke="var(--foreground)"
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 gap-4 center-center">
<div>
<div className="text-2xl font-semibold font-mono">
{totalVisitors.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Visitors</div>
</div>
<div>
<div className="text-2xl font-semibold font-mono text-emerald-500">
${totalRevenue.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Revenue</div>
</div>
</div>
</FeatureCardContainer>
);
}

View File

@@ -0,0 +1,66 @@
import { FeatureCard } from '@/components/feature-card';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import {
ChartBarIcon,
DollarSignIcon,
LayoutDashboardIcon,
RocketIcon,
WorkflowIcon,
} from 'lucide-react';
import Link from 'next/link';
import { CollaborationChart } from './collaboration-chart';
const features = [
{
title: 'Visualize your data',
description:
'See your data in a visual way. You can create advanced reports and more to understand',
icon: ChartBarIcon,
},
{
title: 'Share & Collaborate',
description:
'Build interactive dashboards and share insights with your team. Export reports, set up notifications, and keep everyone aligned.',
icon: LayoutDashboardIcon,
},
{
title: 'Integrations',
description:
'Get notified when new events are created, or forward specific events to your own systems with our east to use integrations.',
icon: WorkflowIcon,
},
];
export function Collaboration() {
return (
<Section className="container">
<div className="grid grid-cols-1 md:grid-cols-2 gap-16">
<CollaborationChart />
<div>
<SectionHeader
title="Turn data into actionable insights"
description="Build interactive dashboards, share insights with your team, and make data-driven decisions faster. OpenPanel helps you understand not just what's happening, but why."
/>
<GetStartedButton className="mt-6" />
<div className="col gap-6 mt-16">
{features.map((feature) => (
<div className="col gap-2" key={feature.title}>
<h3 className="font-semibold">
<feature.icon className="size-6 inline-block mr-2 relative -top-0.5" />
{feature.title}
</h3>
<p className="text-muted-foreground text-sm">
{feature.description}
</p>
</div>
))}
</div>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,81 @@
import { GetStartedButton } from '@/components/get-started-button';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ChevronRightIcon } from 'lucide-react';
import Link from 'next/link';
function Svg({ className }: { className?: string }) {
return (
<svg
width="409"
height="539"
viewBox="0 0 409 539"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('text-foreground', className)}
>
<path
d="M222.146 483.444C332.361 429.581 378.043 296.569 324.18 186.354C270.317 76.1395 137.306 30.4572 27.0911 84.3201"
stroke="url(#paint0_linear_552_3808)"
strokeWidth="123.399"
/>
<defs>
<linearGradient
id="paint0_linear_552_3808"
x1="324.18"
y1="186.354"
x2="161.365"
y2="265.924"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
}
export function CtaBanner({
title = (
<>
Ready to understand your users better?
<br />
Start tracking in minutes
</>
),
description = 'Join thousands of companies using OpenPanel. Free 30-day trial, no credit card required. Self-host for free or use our cloud.',
ctaText,
ctaLink,
}: {
title?: string | React.ReactNode;
description?: string;
ctaText?: string;
ctaLink?: string;
}) {
return (
<div className="container">
<section
className={cn(
'relative overflow-hidden border rounded-3xl py-16 px-4 md:px-16',
)}
>
<div className="size-px absolute left-12 bottom-12 rounded-full shadow-[0_0_250px_80px_var(--color-foreground)]" />
<div className="size-px absolute right-12 top-12 rounded-full shadow-[0_0_250px_80px_var(--color-foreground)]" />
<Svg className="absolute left-0 bottom-0 -translate-x-1/2 translate-y-1/2 max-md:scale-50 opacity-50" />
<Svg className="absolute right-0 top-0 translate-x-1/2 -translate-y-1/2 rotate-105 max-md:scale-50 scale-75 opacity-50" />
<div className="absolute inset-0 bg-linear-to-br from-foreground/5 via-transparent to-foreground/5" />
<div className="container relative z-10 col gap-6 center-center max-w-3xl">
<h2 className="text-4xl md:text-4xl font-semibold text-center">
{title}
</h2>
<p className="text-muted-foreground text-center max-w-md">
{description}
</p>
<GetStartedButton className="mt-4" text={ctaText} href={ctaLink} />
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { BoltIcon, GithubIcon, ServerIcon } from 'lucide-react';
import { DataOwnershipIllustration } from './illustrations/data-ownership';
import { PrivacyIllustration } from './illustrations/privacy';
const secondaryFeatures = [
{
title: 'Open Source',
description:
'Full transparency. Audit the code, contribute, fork it, or self-host without lock-in.',
icon: GithubIcon,
},
{
title: 'Self-hosting',
description:
'Deploy OpenPanel anywhere - your server, your cloud, or locally. Full flexibility.',
icon: ServerIcon,
},
{
title: 'Lightweight & Fast',
description:
"A tiny, high-performance tracker that won't slow down your site.",
icon: BoltIcon,
},
];
export function DataPrivacy() {
return (
<Section className="container">
<SectionHeader
title={
<>
Built for Control,
<br />
Transparency & Trust
</>
}
description="OpenPanel gives you analytics on your terms - privacy-friendly, open-source, and fully self-hostable. Every part of the platform is designed to put you in control of your data while delivering fast, reliable insights without compromising user trust."
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6 mt-16">
<FeatureCard
variant="large"
title="Privacy-first"
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust."
illustration={<PrivacyIllustration />}
/>
<FeatureCard
variant="large"
title="Data Ownership"
description="You own your data - no vendors, no sharing, no hidden processing. Store analytics on your own infrastructure and stay in full control."
illustration={<DataOwnershipIllustration />}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{secondaryFeatures.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
/>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,102 @@
import { FaqItem, Faqs } from '@/components/faq';
import { Section, SectionHeader } from '@/components/section';
import Script from 'next/script';
const faqData = [
{
question: 'Does OpenPanel have a free tier?',
answer:
'For our Cloud plan we offer a 30 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan. OpenPanel is also open-source and you can self-host it for free!\n\nWhy does OpenPanel not have a free tier? We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
},
{
question: 'Is everything really unlimited?',
answer:
'Yes! With OpenPanel, you get unlimited websites/apps, unlimited users, unlimited dashboards, unlimited charts, and unlimited tracked profiles.\n\nThe only limit is the number of events you track per month, which you choose based on your needs.',
},
{
question: 'What is the difference between web and product analytics?',
answer:
'Web analytics focuses on website traffic, page views, and visitor behavior. Product analytics goes deeper, tracking user interactions, events, and product usage patterns.\n\nOpenPanel combines both, giving you a complete view of your users and product performance.',
},
{
question: 'Do I need to modify my code to use OpenPanel?',
answer:
'OpenPanel offers multiple SDKs and integration options. For most frameworks, you can get started with just a few lines of code.\n\nWe provide SDKs for React, Next.js, Vue, Astro, and many more. Check our documentation for your specific framework.',
},
{
question: 'Is my data GDPR compliant?',
answer:
"Yes! OpenPanel is designed with privacy in mind. We use cookie-less tracking, don't collect personal data without consent, and give you full control over your data.\n\nYou can self-host to ensure complete data sovereignty.",
},
{
question: 'How does OpenPanel compare to other analytics tools?',
answer:
'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare).',
},
{
question: 'How does OpenPanel compare to Mixpanel?',
answer:
"OpenPanel offers similar powerful product analytics features as Mixpanel, but with the added benefits of being open-source, more affordable, and including web analytics capabilities.\n\nYou get Mixpanel's power with Plausible's simplicity.",
},
{
question: 'How does OpenPanel compare to Plausible?',
answer:
"OpenPanel shares Plausible's privacy-first approach and simplicity, but adds powerful product analytics capabilities.\n\nWhile Plausible focuses on web analytics, OpenPanel combines both web and product analytics in one platform.",
},
{
question: 'How does OpenPanel compare to Google Analytics?',
answer:
"OpenPanel is a privacy-first alternative to Google Analytics. Unlike GA, we don't use cookies, respect user privacy, and give you full control over your data.\n\nPlus, you get product analytics features that GA doesn't offer.",
},
{
question: 'Can I export my data?',
answer:
'Absolutely! You own your data and can export it anytime. We have API endpoints to get all raw data that we have access to.\n\nIf you self-host, you have direct access and own all your data. For our cloud service, you can always reach out to us if you want a database dump of all your data—perfect if you want to move from cloud to self-hosting.\n\nWe have no lock-in whatsoever.',
},
{
question: 'What kind of support do you offer?',
answer:
'We offer support through our documentation, GitHub issues, and Discord community. For paid plans, we provide email support.\n\nOur team is committed to helping you succeed with OpenPanel.',
},
];
export function Faq() {
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqData.map((q) => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: {
'@type': 'Answer',
text: q.answer,
},
})),
};
return (
<Section className="container">
<Script
strategy="beforeInteractive"
id="faq-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader
className="mb-16"
title="FAQ"
description="Some of the most common questions we get asked."
/>
<Faqs>
{faqData.map((faq) => (
<FaqItem key={faq.question} question={faq.question}>
{faq.answer}
</FaqItem>
))}
</Faqs>
</div>
</Section>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { Competition } from '@/components/competition';
import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks';
import { Tag } from '@/components/tag';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import {
ArrowRightIcon,
CalendarIcon,
ChevronRightIcon,
CookieIcon,
CreditCardIcon,
DatabaseIcon,
FlaskRoundIcon,
GithubIcon,
ServerIcon,
StarIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon },
{ text: 'No credit card required', icon: CreditCardIcon },
{ text: 'Cookie-less tracking', icon: CookieIcon },
{ text: 'Open-source', icon: GithubIcon },
{ text: 'Your data, your rules', icon: DatabaseIcon },
{ text: 'Self-hostable', icon: ServerIcon },
];
const aspectRatio = 2946 / 1329;
const width = 2346;
const height = width / aspectRatio;
function HeroImage({ className }: { className?: string }) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9, x: 0 }}
animate={
isLoaded
? { opacity: 0.5, scale: 1, x: 0 }
: { opacity: 0, scale: 0.9, x: 0 }
}
transition={{
duration: 2,
}}
className={cn('absolute', className)}
style={{
left: `calc(50% - ${width / 2}px - 50px)`,
top: -270,
}}
>
<Image
src="/hero-dark.webp"
alt="Hero"
width={width}
height={height}
className="hidden dark:block"
style={{
width,
minWidth: width,
height,
}}
onLoad={() => setIsLoaded(true)}
/>
<Image
src="/hero-light.webp"
alt="Hero"
width={width}
height={height}
className="dark:hidden"
style={{
width,
minWidth: width,
height,
}}
onLoad={() => setIsLoaded(true)}
/>
</motion.div>
);
}
export function Hero() {
return (
<HeroContainer className="-mb-32 max-sm:**:data-children:pb-0">
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
<div className="col gap-4">
{/* <div className="font-mono text-sm text-muted-foreground">
TRUSTED BY 1,000+ COMPANIES • 4.7K GITHUB STARS
</div> */}
<h1 className="text-4xl md:text-5xl font-semibold leading-[1.1]">
An open-source alternative to <Competition />
</h1>
<p className="text-lg text-muted-foreground">
An open-source web and product analytics platform that combines the
power of Mixpanel with the ease of Plausible and one of the best
Google Analytics replacements.
</p>
</div>
<div className="row gap-4">
<GetStartedButton />
<Button size="lg" variant="outline" asChild className="px-6">
<Link
href="https://demo.openpanel.dev/demo/shoey"
rel="noreferrer noopener nofollow"
target="_blank"
>
Test live demo
</Link>
</Button>
</div>
<Perks perks={perks} />
</div>
<div className="col sm:w-1/2 relative group max-sm:px-4">
<div
className={cn([
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-64',
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
])}
>
{/* Window controls */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
{/* URL bar */}
<a
target="_blank"
rel="noreferrer noopener nofollow"
href="https://demo.openpanel.dev/demo/shoey"
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background/20 rounded-md border border-border flex items-center gap-2"
>
<span className="text-muted-foreground flex-1">
https://demo.openpanel.dev
</span>
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
</div>
<iframe
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
className="w-full h-full"
title="Live preview"
scrolling="no"
/>
</div>
</div>
</HeroContainer>
);
}
export function HeroContainer({
children,
className,
divider = true,
}: {
children?: React.ReactNode;
className?: string;
divider?: boolean;
}): React.ReactElement {
return (
<section
className={cn('relative z-10', divider && 'overflow-hidden', className)}
>
<div className="absolute inset-0 w-screen overflow-x-clip">
<HeroImage />
</div>
<div
className="container relative col sm:row py-44 max-sm:pt-32"
data-children
>
{children}
</div>
{divider && (
<div className="absolute bottom-0 left-0 right-0 h-20 border-t border-border rounded-t-[3rem] md:rounded-t-[6rem] bg-background shadow-[0_0_100px_var(--background)]" />
)}
</section>
);
}

View File

@@ -0,0 +1,113 @@
import React from 'react';
type IllustrationProps = {
className?: string;
};
export function DataOwnershipIllustration({
className = '',
}: IllustrationProps) {
return (
<div>
{/* Main layout */}
<div className="relative grid aspect-2/1 grid-cols-5 gap-3">
{/* Left: your server card */}
<div
className="
col-span-3 rounded-2xl border border-border bg-card/80
p-3 sm:p-4 shadow-xl backdrop-blur
transition-all duration-300
group-hover:-translate-y-1 group-hover:-translate-x-0.5
"
>
<div className="flex items-center justify-between text-xs text-foreground">
<span>Your server</span>
<span className="flex items-center gap-1 rounded-full bg-card/80 px-2 py-0.5 text-[10px] text-blue-300">
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
In control
</span>
</div>
{/* "Server" visual */}
<div className="mt-3 space-y-2">
<div className="flex gap-1.5">
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
<p className="text-[10px] text-muted-foreground">Region</p>
<p className="text-xs font-medium text-foreground">
EU / Custom
</p>
</div>
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
<p className="text-[10px] text-muted-foreground">Retention</p>
<p className="text-xs font-medium text-foreground">
Configurable
</p>
</div>
</div>
{/* mini "database"/requests strip */}
<div className="mt-1 rounded-xl border border-border bg-card/90 px-3 py-2 text-[11px] text-foreground">
<div className="flex items-center justify-between">
<span>Events stored</span>
<span className="text-[10px] text-muted-foreground">
locally
</span>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-1.5 flex-1 rounded-full bg-blue-400/70" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/40" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/20" />
</div>
</div>
<div className="mt-1 rounded-xl border border-border bg-card/90 px-3 py-2 text-[11px] text-foreground">
<div className="flex items-center justify-between">
<span>CPU</span>
<span className="text-[10px] text-muted-foreground">20%</span>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-1.5 flex-1 rounded-full bg-blue-400/70" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/40" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/20" />
</div>
</div>
</div>
</div>
{/* Right: third-party contrast */}
<div
className="
col-span-2 rounded-2xl border border-border/80 bg-card/40
p-3 text-[11px] text-muted-foreground
transition-all duration-300
group-hover:translate-y-1 group-hover:translate-x-0.5 group-hover:opacity-70
"
>
<p className="text-xs text-muted-foreground mb-2">or use our cloud</p>
<ul className="space-y-1.5">
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
Zero server setup
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
Auto-scaling & backups
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
99.9% uptime
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
24/7 support
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
Export data anytime
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
type IllustrationProps = {
className?: string;
};
export function PrivacyIllustration({ className = '' }: IllustrationProps) {
return (
<div>
{/* Floating cards */}
<div className="relative aspect-3/2 md:aspect-2/1">
{/* Back card */}
<div
className="
absolute top-0 left-0 right-10 bottom-10 rounded-2xl border border-border/80 bg-card/70
backdrop-blur-sm shadow-lg
transition-all duration-300
group-hover:-translate-y-1 group-hover:-rotate-2
"
>
<div className="flex items-center justify-between px-4 pt-3 text-xs text-muted-foreground">
<span>Session duration</span>
<span className="flex items-center gap-1">
3m 12s
<span className="text-[10px] text-blue-400">+8%</span>
</span>
</div>
{/* Simple line chart */}
<div className="mt-3 px-4">
<svg
viewBox="0 0 120 40"
className="h-16 w-full text-muted-foreground"
>
<path
d="M2 32 L22 18 L40 24 L60 10 L78 16 L96 8 L118 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="opacity-60"
/>
<circle cx="118" cy="14" r="2.5" className="fill-blue-400" />
</svg>
</div>
</div>
{/* Front card */}
<div
className="
col
absolute top-10 left-4 right-0 bottom-0 rounded-2xl border border-border/80
bg-card shadow-xl
transition-all duration-300
group-hover:translate-y-1 group-hover:rotate-2
"
>
<div className="flex items-center justify-between px-4 pt-3 text-xs text-foreground">
<span>Anonymous visitors</span>
<span className="text-[10px] rounded-full bg-card px-2 py-0.5 text-muted-foreground">
No cookies
</span>
</div>
<div className="flex items-end justify-between px-4 pt-4 pb-3">
<div>
<p className="text-[11px] text-muted-foreground mb-1">
Active now
</p>
<p className="text-2xl font-semibold text-foreground">128</p>
</div>
<div className="space-y-1.5 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span>IP + UA hashed daily</span>
</div>
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span>No fingerprinting</span>
</div>
</div>
</div>
{/* "Sources" row */}
<div className="mt-auto flex gap-2 border-t border-border px-3 py-2.5 text-[11px]">
<div className="flex-1 rounded-xl bg-card/90 px-3 py-1.5 flex items-center justify-between">
<span className="text-muted-foreground">Direct</span>
<span className="text-foreground">42%</span>
</div>
<div className="flex-1 rounded-xl bg-card/90 px-3 py-1.5 flex items-center justify-between">
<span className="text-muted-foreground">Organic</span>
<span className="text-foreground">58%</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { cn } from '@/lib/utils';
import { ResponsiveFunnel } from '@nivo/funnel';
import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion, useSpring } from 'framer-motion';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
function useFunnelSteps() {
const { resolvedTheme } = useTheme();
return [
{
id: 'Visitors',
label: 'Visitors',
value: 10000,
percentage: 100,
color: resolvedTheme === 'dark' ? '#333' : '#888',
},
{
id: 'Add to cart',
label: 'Add to cart',
value: 7000,
percentage: 32,
color: resolvedTheme === 'dark' ? '#222' : '#999',
},
{
id: 'Checkout',
label: 'Checkout',
value: 5000,
percentage: 8.9,
color: resolvedTheme === 'dark' ? '#111' : '#e1e1e1',
},
];
}
export function ProductAnalyticsIllustration() {
return (
<div className="aspect-video">
<FunnelVisualization />
</div>
);
}
export const PartLabel = ({ part }: { part: any }) => {
const { resolvedTheme } = useTheme();
return (
<g transform={`translate(${part.x}, ${part.y})`}>
<text
textAnchor="middle"
dominantBaseline="central"
style={{
fill: resolvedTheme === 'dark' ? '#fff' : '#000',
pointerEvents: 'none',
fontSize: 12,
fontWeight: 500,
}}
>
{part.data.label}
</text>
</g>
);
};
function Labels(props: any) {
return props.parts.map((part: any) => (
<PartLabel key={part.data.id} part={part} />
));
}
function FunnelVisualization() {
const funnelSteps = useFunnelSteps();
const colors = funnelSteps.map((stage) => stage.color);
const nivoData = funnelSteps.map((stage) => ({
id: stage.id,
value: stage.value,
label: stage.label,
}));
return (
<div className="w-full h-full">
<ResponsiveFunnel
data={nivoData}
margin={{ top: 20, right: 0, bottom: 20, left: 0 }}
direction="horizontal"
shapeBlending={0.6}
colors={colors}
enableBeforeSeparators={false}
enableAfterSeparators={false}
beforeSeparatorLength={0}
afterSeparatorLength={0}
afterSeparatorOffset={0}
beforeSeparatorOffset={0}
currentPartSizeExtension={5}
borderWidth={20}
currentBorderWidth={15}
tooltip={() => null}
layers={['parts', Labels]}
/>
</div>
);
}

View File

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

View File

@@ -0,0 +1,119 @@
'use client';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react';
import { PRICING } from '@openpanel/payments/prices';
import { CheckIcon, StarIcon } from 'lucide-react';
import { useState } from 'react';
import { formatEventsCount } from '@/lib/utils';
const features = [
'Unlimited websites or apps',
'Unlimited users',
'Unlimited dashboards',
'Unlimited charts',
'Unlimited tracked profiles',
'Yes, we have no limits or hidden costs',
];
export function Pricing() {
const [selectedIndex, setSelectedIndex] = useState(2);
const selected = PRICING[selectedIndex];
return (
<Section className="container">
<div className="col md:row gap-16">
<div className="w-full md:w-1/3 min-w-sm col gap-4 border rounded-3xl p-6 bg-linear-to-b from-card to-background">
<p className="text-sm text-muted-foreground">
Choose how many events you'll track this month
</p>
<div className="row flex-wrap gap-2">
{PRICING.map((tier, index) => (
<Button
key={tier.price}
variant={selectedIndex === index ? 'default' : 'outline'}
size="sm"
className={cn('h-8 rounded-full relative px-4 border')}
onClick={() => setSelectedIndex(index)}
>
{tier.popular && <StarIcon className="size-4" />}
{formatEventsCount(tier.events)}
</Button>
))}
<Button
variant={selectedIndex === -1 ? 'default' : 'outline'}
size="sm"
className={cn('h-8 rounded-full relative px-4 border')}
onClick={() => setSelectedIndex(-1)}
>
Custom
</Button>
</div>
<div className="col items-baseline mt-8 md:mt-auto w-full">
{selected ? (
<>
<NumberFlow
className="text-5xl font-bold"
value={selected.price}
format={{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}}
locales={'en-US'}
/>
<div className="row justify-between w-full">
<span className="text-muted-foreground/80 text-sm -mt-2">
Per month
</span>
<span className="text-muted-foreground/80 text-sm -mt-2">
+ VAT if applicable
</span>
</div>
</>
) : (
<div className="text-lg">
Contact us at{' '}
<a className="underline" href="mailto:hello@openpanel.dev">
hello@openpanel.dev
</a>{' '}
to get a custom quote.
</div>
)}
</div>
</div>
<div className="col gap-8 justify-center flex-1 shrink-0">
<div className="col gap-4">
<SectionHeader
title="Simple, transparent pricing"
description="Pay only for what you use. Choose your event volume - everything else is unlimited. No surprises, no hidden fees."
/>
</div>
<ul className="col gap-2">
{features.map((feature) => (
<li key={feature} className="row gap-2 items-start text-sm">
<CheckIcon className="size-4 shrink-0 mt-0.5" />
<span>{feature}</span>
</li>
))}
</ul>
<GetStartedButton className="w-fit" />
<p className="text-sm text-muted-foreground">
All features are included upfront - no hidden costs. You choose how
many events to track each month.
</p>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,30 @@
import { FeatureCardContainer } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { frameworks } from '@openpanel/sdk-info';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
export function Sdks() {
return (
<Section className="container">
<SectionHeader
title="Get started in minutes"
description="Integrate OpenPanel with your favorite framework using our lightweight SDKs. A few lines of code and you're tracking."
className="mb-16"
/>
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
{frameworks.map((sdk) => (
<Link href={sdk.href} key={sdk.key}>
<FeatureCardContainer key={sdk.key}>
<sdk.IconComponent className="size-6" />
<div className="row justify-between items-center">
<span className="text-sm font-semibold">{sdk.name}</span>
<ArrowRightIcon className="size-4" />
</div>
</FeatureCardContainer>
</Link>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { InfiniteMovingCards } from '@/components/infinite-moving-cards';
import { Section, SectionHeader } from '@/components/section';
import { TwitterCard } from '@/components/twitter-card';
import { useEffect, useMemo, useRef } from 'react';
const testimonials = [
{
verified: true,
avatarUrl: '/twitter-steven.jpg',
name: 'Steven Tey',
handle: 'steventey',
content: [
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
'Built by @CarlLindesvard and its already tracking 750K+ events 🤩',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-pontus.jpg',
name: 'Pontus Abrahamsson - oss/acc',
handle: 'pontusab',
content: ['Thanks, OpenPanel is a beast, love it!'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-piotr.jpg',
name: 'Piotr Kulpinski',
handle: 'piotrkulpinski',
content: [
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-greg.png',
name: 'greg hodson 🍜',
handle: 'h0dson',
content: ['i second this, openpanel is killing it'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-jacob.jpg',
name: 'Jacob 🍀 Build in Public',
handle: 'javayhuwx',
content: [
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
'#buildinpublic #indiehackers',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-lee.jpg',
name: 'Lee',
handle: 'DutchEngIishman',
content: [
'Day two of marketing.',
'I like this upward trend..',
'P.S. website went live on Sunday',
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
],
replies: 25,
retweets: 68,
likes: 648,
},
];
export function Testimonials() {
const scrollerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number>(0);
const isPausedRef = useRef(false);
// Duplicate items to create the illusion of infinite scrolling
const duplicatedTestimonials = useMemo(
() => [...testimonials, ...testimonials],
[],
);
useEffect(() => {
const scrollerElement = scrollerRef.current;
if (!scrollerElement) return;
const handleScroll = () => {
// When we've scrolled to the end of the first set, reset to the beginning
// This creates a seamless infinite scroll effect
const scrollWidth = scrollerElement.scrollWidth;
const clientWidth = scrollerElement.clientWidth;
const scrollLeft = scrollerElement.scrollLeft;
// Reset scroll position when we reach halfway (end of first set)
if (scrollLeft + clientWidth >= scrollWidth / 2) {
scrollerElement.scrollLeft = scrollLeft - scrollWidth / 2;
}
};
// Auto-scroll functionality
const autoScroll = () => {
if (!isPausedRef.current && scrollerElement) {
scrollerElement.scrollLeft += 0.5; // Adjust speed here
animationFrameRef.current = requestAnimationFrame(autoScroll);
}
};
scrollerElement.addEventListener('scroll', handleScroll);
// Start auto-scrolling
animationFrameRef.current = requestAnimationFrame(autoScroll);
// Pause on hover
const handleMouseEnter = () => {
isPausedRef.current = true;
};
const handleMouseLeave = () => {
isPausedRef.current = false;
animationFrameRef.current = requestAnimationFrame(autoScroll);
};
scrollerElement.addEventListener('mouseenter', handleMouseEnter);
scrollerElement.addEventListener('mouseleave', handleMouseLeave);
return () => {
scrollerElement.removeEventListener('scroll', handleScroll);
scrollerElement.removeEventListener('mouseenter', handleMouseEnter);
scrollerElement.removeEventListener('mouseleave', handleMouseLeave);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
return (
<Section className="overflow-hidden">
<div className="container mb-16">
<SectionHeader
title="Loved by builders everywhere"
description="From indie hackers to global teams, OpenPanel helps people understand their users effortlessly."
/>
</div>
<div className="relative -mx-4 px-4">
{/* Gradient masks for fade effect */}
<div
className="absolute left-0 top-0 bottom-0 w-32 z-10 pointer-events-none"
style={{
background:
'linear-gradient(to right, hsl(var(--background)), transparent)',
}}
/>
<div
className="absolute right-0 top-0 bottom-0 w-32 z-10 pointer-events-none"
style={{
background:
'linear-gradient(to left, hsl(var(--background)), transparent)',
}}
/>
<InfiniteMovingCards
items={testimonials}
direction="left"
pauseOnHover
speed="slow"
className="gap-8"
renderItem={(item) => (
<TwitterCard
name={item.name}
handle={item.handle}
content={item.content}
avatarUrl={item.avatarUrl}
/>
)}
/>
</div>
</Section>
);
}

View File

@@ -0,0 +1,82 @@
import { FeatureCardBackground } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { Tag } from '@/components/tag';
import { cn } from '@/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import Image from 'next/image';
const images = [
{
name: 'Helpy UI',
url: 'https://helpy-ui.com',
logo: '/logos/helpy-ui.png',
className: 'size-12',
},
{
name: 'KiddoKitchen',
url: 'https://kiddokitchen.se',
logo: '/logos/kiddokitchen.png',
},
{
name: 'Maneken',
url: 'https://maneken.app',
logo: '/logos/maneken.png',
},
{
name: 'Midday',
url: 'https://midday.ai',
logo: '/logos/midday.png',
},
{
name: 'Screenzen',
url: 'https://www.screenzen.co',
logo: '/logos/screenzen.png',
},
{
name: 'Tiptip',
url: 'https://tiptip.id',
logo: '/logos/tiptip.png',
},
];
export function WhyOpenPanel() {
return (
<Section className="container gap-16">
<SectionHeader
label="Trusted by builders"
title="Join thousands of companies using OpenPanel to understand their users"
/>
<div className="col overflow-hidden">
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4">
USED BY
</SectionLabel>
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4">
{images.map((image) => (
<div key={image.logo} className="px-4 border-r last:border-r-0 ">
<a
href={image.url}
target="_blank"
rel="noopener noreferrer nofollow"
key={image.logo}
className={cn('relative group center-center aspect-square')}
title={image.name}
>
<FeatureCardBackground />
<Image
src={image.logo}
alt={image.name}
width={64}
height={64}
className={cn(
'size-16 object-contain dark:invert',
image.className,
)}
/>
</a>
</div>
))}
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,17 @@
import { Footer } from '@/components/footer';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
export default function Layout({
children,
}: {
children: ReactNode;
}): React.ReactElement {
return (
<>
<Navbar />
<main className="overflow-hidden">{children}</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,27 @@
import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration';
import { CtaBanner } from './_sections/cta-banner';
import { DataPrivacy } from './_sections/data-privacy';
import { Faq } from './_sections/faq';
import { Hero } from './_sections/hero';
import { Pricing } from './_sections/pricing';
import { Sdks } from './_sections/sdks';
import { Testimonials } from './_sections/testimonials';
import { WhyOpenPanel } from './_sections/why-openpanel';
export default function HomePage() {
return (
<>
<Hero />
<WhyOpenPanel />
<AnalyticsInsights />
<Collaboration />
<Testimonials />
<Pricing />
<DataPrivacy />
<Sdks />
<Faq />
<CtaBanner />
</>
);
}

View File

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

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
const IP_HEADER_ORDER = [
'cf-connecting-ip',
'true-client-ip',
'x-vercel-forwarded-for', // Vercel-specific, most reliable on Vercel
'x-forwarded-for', // Standard proxy header (first IP in chain)
'x-real-ip', // Alternative header
'x-client-ip',
'fastly-client-ip',
'do-connecting-ip',
'x-cluster-client-ip',
];
export const GET = function POST(req: Request) {
return NextResponse.json({
headers: Object.fromEntries(req.headers),
ips: IP_HEADER_ORDER.reduce(
(acc, header) => {
const value = req.headers.get(header);
if (value) {
acc[header] = value;
}
return acc;
},
{} as Record<string, string>,
),
});
};

View File

@@ -0,0 +1,7 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});

View File

@@ -0,0 +1,55 @@
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { getPageImage, source } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
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={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return getPageMetadata({
title: page.data.title,
url: url(page.url),
description: page.data.description ?? '',
image: getOgImageUrl(page.url),
});
}

View File

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

View File

@@ -0,0 +1,207 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@custom-variant dark (&:is(.dark *));
@custom-variant light (&:is(.light *));
@custom-variant system (&:is(.system *));
body {
@apply m-0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--spacing-fd-container: 1000px;
}
@utility container {
@media (width >= 40rem) {
@apply max-w-[1000px];
}
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
/* Light mode colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Fonts */
--font-family-mono: var(--font-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
/* Scroll animations */
--animate-scroll: scroll var(--animation-duration, 40s) var(--animation-direction, forwards) linear infinite;
@keyframes scroll {
to {
transform: translate(calc(-50% - 0.5rem));
}
}
}
@utility pulled {
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
}
@utility center-center {
@apply flex items-center justify-center text-center;
}
@utility col {
@apply flex flex-col;
}
@utility row {
@apply flex flex-row;
}
@utility text-text {
@apply text-foreground;
}
@utility scrollbar-hidden {
&::-webkit-scrollbar {
display: none;
}
}
@keyframes scroll-testimonials {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.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);
}

View File

@@ -0,0 +1,44 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { getRootMetadata } from '@/lib/metadata';
import { cn } from '@/lib/utils';
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './global.css';
const font = Geist({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
const mono = Geist_Mono({
subsets: ['latin'],
variable: '--font-mono',
});
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' },
],
};
export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html
lang="en"
className={cn(font.className, mono.variable)}
suppressHydrationWarning
>
<body className="flex flex-col min-h-screen bg-background">
<RootProvider>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,10 @@
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}

View File

@@ -0,0 +1,233 @@
import { getAllCompareSlugs, getCompareData } from '@/lib/compare';
import { url as baseUrl } from '@/lib/layout.shared';
import { articleSource, pageSource, source } from '@/lib/source';
import { ImageResponse } from 'next/og';
import type { NextRequest } from 'next/server';
// Truncate text helper
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength).trim()}...`;
}
async function getOgData(
segments: string[],
): Promise<{ title: string; description?: string }> {
switch (segments[0]) {
case 'default':
return {
title: 'Home',
description: 'Home page',
};
case 'supporter': {
return {
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
};
}
case 'pricing': {
return {
title: 'Pricing',
description:
'Our pricing is as simple as it gets, choose how many events you want to track each month, everything else is unlimited, no tiers, no hidden costs.',
};
}
case 'articles': {
if (segments.length > 1) {
const data = await articleSource.getPage(segments.slice(1));
return {
title: data?.data.title ?? 'Article Not Found',
description:
data?.data.description || 'Whooops, could not find this article',
};
}
return {
title: 'Articles',
description:
'Read our latest articles and stay up to date with the latest news and updates.',
};
}
case 'compare': {
const slug = segments[1];
if (!slug) {
return {
title: 'Compare alternatives',
description: 'Compare OpenPanel with other analytics tools',
};
}
const data = await getCompareData(slug);
return {
title: data?.seo.title || data?.hero.heading || 'Compare',
description: data?.seo.description || data?.hero.subheading,
};
}
case 'docs': {
const data = await source.getPage(segments.slice(1));
return {
title: data?.data.title ?? 'Page Not Found',
description:
data?.data.description || 'Whooops, could not find this page',
};
}
default: {
const data = await pageSource.getPage(segments);
return {
title: data?.data.title || 'Page Not Found',
description:
data?.data.description || 'Whooops, could not find this page',
};
}
}
}
// export async function generateStaticParams() {
// const params: { og: string[] }[] = [];
// // Static pages
// params.push({ og: ['default'] });
// params.push({ og: ['supporter'] });
// params.push({ og: ['pricing'] });
// params.push({ og: ['articles'] });
// params.push({ og: ['compare'] });
// params.push({ og: ['docs'] });
// // Articles
// const articles = await articleSource.getPages();
// for (const article of articles) {
// const slug = article.url.replace(/^\/articles\//, '').replace(/\/$/, '');
// params.push({ og: ['articles', slug] });
// }
// // Compare pages
// const compareSlugs = await getAllCompareSlugs();
// for (const slug of compareSlugs) {
// params.push({ og: ['compare', slug] });
// }
// // Docs pages
// const docs = await source.getPages();
// for (const doc of docs) {
// params.push({ og: ['docs', ...doc.slugs] });
// }
// // Other pages
// const pages = await pageSource.getPages();
// for (const page of pages) {
// params.push({ og: page.slugs });
// }
// return params;
// }
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ og: string[] }> },
) {
try {
const { og } = await params;
// Get OG data based on segments
const { title, description } = await getOgData(og);
// Truncate title and description
const truncatedTitle = truncateText(title, 100);
const truncatedDescription = description
? truncateText(description, 200)
: undefined;
// Get background image URL
const backgroundImageUrl = baseUrl('/ogimage-empty.png');
// Fetch Geist font files from CDN (cache fonts for better performance)
const [geistRegular, geistBold] = await Promise.all([
fetch(
'https://cdn.jsdelivr.net/npm/geist@1.5.1/dist/fonts/geist-sans/Geist-Regular.ttf',
).then((res) => res.arrayBuffer()),
fetch(
'https://cdn.jsdelivr.net/npm/geist@1.5.1/dist/fonts/geist-sans/Geist-Bold.ttf',
).then((res) => res.arrayBuffer()),
]);
return new ImageResponse(
<div
style={{
height: '100%',
width: '100%',
position: 'relative',
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
}}
>
{/* Title and Description at bottom left */}
<div
style={{
bottom: '55px',
left: '55px',
maxWidth: '900px',
display: 'flex',
flexDirection: 'column',
marginTop: 'auto',
}}
>
{/* Title */}
<div
style={{
fontSize: truncatedTitle.length > 40 ? '56px' : '72px',
fontFamily: 'GeistBold',
color: '#000',
lineHeight: 1.1,
marginBottom: truncatedDescription ? '20px' : '0',
fontWeight: 700,
}}
>
{truncatedTitle}
</div>
{/* Description */}
{truncatedDescription ? (
<div
style={{
fontSize: '30px',
fontFamily: 'Geist',
color: '#666',
lineHeight: 1.4,
fontWeight: 400,
}}
>
{truncatedDescription}
</div>
) : null}
</div>
</div>,
{
headers: {
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
},
width: 1200,
height: 630,
fonts: [
{
name: 'Geist',
data: geistRegular,
style: 'normal',
weight: 400,
},
{
name: 'GeistBold',
data: geistBold,
style: 'normal',
weight: 700,
},
],
},
);
} catch (e: any) {
console.error(`Failed to generate OG image: ${e.message}`);
return new Response('Failed to generate the image', {
status: 500,
});
}
}

View File

@@ -0,0 +1,4 @@
User-Agent: *
Allow: /
Allow: /og*
Sitemap: https://openpanel.dev/sitemap.xml

View File

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

View File

@@ -0,0 +1,43 @@
import Image from 'next/image';
import Link from 'next/link';
export function ArticleCard({
url,
title,
tag,
cover,
team,
date,
}: {
url: string;
title: string;
tag?: string;
cover: string;
team?: string;
date: Date;
}) {
return (
<Link
href={url}
key={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={cover}
alt={title}
width={323}
height={181}
className="w-full"
/>
<span className="p-4 col flex-1">
{tag && <span className="font-mono text-xs mb-2">{tag}</span>}
<span className="flex-1 mb-6">
<h2 className="text-xl font-semibold">{title}</h2>
</span>
<p className="text-sm text-muted-foreground">
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
</p>
</span>
</Link>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
type LucideProps,
} from 'lucide-react';
import { useEffect, useState } from 'react';
export function BatteryIcon(props: LucideProps) {
const [index, setIndex] = useState(0);
const icons = [BatteryLowIcon, BatteryMediumIcon, BatteryFullIcon];
const Icon = icons[index];
useEffect(() => {
const interval = setInterval(() => {
setIndex((index + 1) % icons.length);
}, 750);
return () => clearInterval(interval);
}, [index]);
if (!Icon) {
return <div className={props.className} />;
}
return <Icon {...props} />;
}

View File

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

View File

@@ -0,0 +1,80 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
const brandConfig = {
Mixpanel: '#7A59FF',
'Google Analytics': '#E37400',
Amplitude: '#00CF98',
} as const;
const words = Object.keys(brandConfig);
function useWordCycle(words: string[], interval: number, mounted: boolean) {
const [index, setIndex] = useState(0);
const [isInitial, setIsInitial] = useState(true);
useEffect(() => {
if (!mounted) {
return;
}
if (isInitial) {
setIndex(Math.floor(Math.random() * words.length));
setIsInitial(false);
return;
}
const timer = setInterval(() => {
setIndex((current) => (current + 1) % words.length);
}, interval);
return () => clearInterval(timer);
}, [words, interval, isInitial, mounted]);
return words[index];
}
export function Competition() {
const [mounted, setMounted] = useState(false);
const word = useWordCycle(words, 2100, mounted);
const color = brandConfig[word as keyof typeof brandConfig];
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<span className="block truncate leading-tight -mt-1" style={{ color }}>
{word}
</span>
);
}
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={word}
className="block truncate leading-tight -mt-1"
style={{ color }}
>
{word?.split('').map((char, index) => (
<motion.span
key={`${word}-${char}-${index.toString()}`}
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{
duration: 0.15,
delay: index * 0.015,
ease: 'easeOut',
}}
style={{ display: 'inline-block', whiteSpace: 'pre' }}
>
{char}
</motion.span>
))}
</motion.div>
</AnimatePresence>
);
}

View 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&apos;s up to you.**
</Callout>
);
}

View File

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

View File

@@ -0,0 +1,76 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
interface FeatureCardProps {
illustration?: React.ReactNode;
title: string;
description: string;
icon?: LucideIcon;
children?: React.ReactNode;
className?: string;
variant?: 'default' | 'large';
}
interface FeatureCardContainerProps {
children: React.ReactNode;
className?: string;
}
export const FeatureCardBackground = () => (
<div
className={cn(
'pointer-events-none absolute inset-0 bg-linear-to-br opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100',
'dark:from-blue-500/10 dark:via-transparent dark:to-emerald-500/5',
'light:from-blue-800/20 light:via-transparent light:to-emerald-900/10',
)}
/>
);
export function FeatureCardContainer({
children,
className,
}: FeatureCardContainerProps) {
return (
<div
className={cn(
'col gap-8 p-6 rounded-3xl border bg-background group relative overflow-hidden',
className,
)}
>
<FeatureCardBackground />
{children}
</div>
);
}
export function FeatureCard({
illustration,
title,
description,
icon: Icon,
children,
className,
}: FeatureCardProps) {
if (illustration) {
return (
<FeatureCardContainer className={className}>
{illustration}
<div className="col gap-2" data-content>
<h3 className="text-xl font-semibold">{title}</h3>
<p className="text-muted-foreground">{description}</p>
</div>
{children}
</FeatureCardContainer>
);
}
return (
<FeatureCardContainer className={className}>
{Icon && <Icon className="size-6" />}
<div className="col gap-2">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</FeatureCardContainer>
);
}

View File

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

View File

@@ -0,0 +1,79 @@
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
import type { ReactNode } from 'react';
interface FlowStepProps {
step: number;
actor: string;
description: string;
children?: ReactNode;
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
isLast?: boolean;
}
const iconMap = {
visitor: User,
website: Globe,
backend: Server,
payment: CreditCard,
success: CheckCircle,
};
const iconColorMap = {
visitor: 'text-blue-500',
website: 'text-green-500',
backend: 'text-purple-500',
payment: 'text-yellow-500',
success: 'text-green-600',
};
const iconBorderColorMap = {
visitor: 'border-blue-500',
website: 'border-green-500',
backend: 'border-purple-500',
payment: 'border-yellow-500',
success: 'border-green-600',
};
export function FlowStep({
step,
actor,
description,
children,
icon = 'visitor',
isLast = false,
}: FlowStepProps) {
const Icon = iconMap[icon];
return (
<div className="relative flex gap-4 mb-4 min-w-0">
{/* Step number and icon */}
<div className="flex flex-col items-center shrink-0">
<div className="relative z-10 bg-background">
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
{step}
</div>
<div
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
>
<Icon
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
/>
</div>
</div>
{/* Connector line - extends from badge through content to next step */}
{!isLast && (
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
)}
</div>
{/* Content */}
<div className="flex-1 pt-1 min-w-0">
<div className="mb-2">
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
<span className="text-muted-foreground">{description}</span>
</div>
{children && <div className="mt-3 min-w-0">{children}</div>}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils';
import { ChevronRightIcon } from 'lucide-react';
import Link from 'next/link';
import { Button } from './ui/button';
export function GetStartedButton({
text,
href = 'https://dashboard.openpanel.dev/onboarding',
className,
}: {
text?: React.ReactNode;
className?: string;
href?: string;
}) {
return (
<Button size="lg" asChild className={cn('group', className)}>
<Link href={href}>
{text ?? 'Get started now'}
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
</Link>
</Button>
);
}

View File

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

View File

@@ -0,0 +1,99 @@
// Thank you: https://ui.aceternity.com/components/infinite-moving-cards
import { cn } from '@/lib/utils';
import React, { useEffect, useState } from 'react';
export const InfiniteMovingCards = <T,>({
items,
direction = 'left',
speed = 'fast',
pauseOnHover = true,
className,
renderItem,
}: {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
direction?: 'left' | 'right';
speed?: 'fast' | 'normal' | 'slow';
pauseOnHover?: boolean;
className?: string;
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLUListElement>(null);
useEffect(() => {
addAnimation();
}, []);
const [start, setStart] = useState(false);
function addAnimation() {
if (containerRef.current && scrollerRef.current) {
const scrollerContent = Array.from(scrollerRef.current.children);
scrollerContent.forEach((item) => {
const duplicatedItem = item.cloneNode(true);
if (scrollerRef.current) {
scrollerRef.current.appendChild(duplicatedItem);
}
});
getDirection();
getSpeed();
setStart(true);
}
}
const getDirection = () => {
if (containerRef.current) {
if (direction === 'left') {
containerRef.current.style.setProperty(
'--animation-direction',
'forwards',
);
} else {
containerRef.current.style.setProperty(
'--animation-direction',
'reverse',
);
}
}
};
const getSpeed = () => {
if (containerRef.current) {
if (speed === 'fast') {
containerRef.current.style.setProperty('--animation-duration', '20s');
} else if (speed === 'normal') {
containerRef.current.style.setProperty('--animation-duration', '40s');
} else {
containerRef.current.style.setProperty('--animation-duration', '80s');
}
}
};
return (
<div
ref={containerRef}
className={cn(
'scroller relative z-20 overflow-hidden -ml-4 md:-ml-[1200px] w-screen md:w-[calc(100vw+1400px)]',
className,
)}
>
<ul
ref={scrollerRef}
className={cn(
'flex min-w-full shrink-0 gap-8 py-4 w-max flex-nowrap items-start',
start && 'animate-scroll',
pauseOnHover && 'hover:[animation-play-state:paused]',
)}
>
{items.map((item, idx) => (
<li
className="w-[310px] max-w-full relative shrink-0 md:w-[400px]"
key={idx.toString()}
>
{renderItem(item, idx)}
</li>
))}
</ul>
</div>
);
};

View File

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

View File

@@ -0,0 +1,216 @@
'use client';
import { baseOptions } from '@/lib/layout.shared';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { MenuIcon, MoonIcon, SunIcon, XIcon } from 'lucide-react';
import { useTheme } from 'next-themes';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { FeatureCardContainer } from './feature-card';
import { GithubButton } from './github-button';
import { Logo } from './logo';
import { Button } from './ui/button';
const LINKS = [
{
text: 'Home',
url: '/',
},
{
text: 'Pricing',
url: '/pricing',
},
{
text: 'Supporter',
url: '/supporter',
},
{
text: 'Docs',
url: '/docs',
},
{
text: 'Articles',
url: '/articles',
},
];
const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navbarRef = useRef<HTMLDivElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
// If click outside of the menu, close it
const handleClick = (e: MouseEvent) => {
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
setIsMobileMenuOpen(false);
}
};
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [isMobileMenuOpen]);
return (
<nav
className={cn(
'fixed top-0 z-50 w-full py-4 border-b transition-colors duration-500',
isScrolled
? 'bg-background border-border'
: 'bg-background/0 border-border/0',
)}
ref={navbarRef}
>
<div className="container">
<div className={cn('flex justify-between items-center')}>
{/* Logo */}
<div className="shrink-0">
<Link href="/" className="row items-center font-medium">
<Logo className="h-6" />
<span className="hidden [@media(min-width:850px)]:block">
{baseOptions().nav?.title}
</span>
</Link>
</div>
<div className="row items-center gap-8">
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8 text-sm">
{LINKS?.map((link) => {
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground font-medium"
>
{link.text}
</Link>
);
})}
</div>
{/* Right side buttons */}
<div className="flex items-center gap-2">
<GithubButton />
{/* Sign in button */}
<Button asChild>
<Link
className="hidden md:flex"
href="https://dashboard.openpanel.dev/onboarding"
>
Sign up
</Link>
</Button>
<ThemeToggle />
<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
ref={mobileMenuRef}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden bg-background/20 md:hidden backdrop-blur-lg fixed inset-0 p-4"
onClick={(e) => {
const target = e.target as HTMLElement;
if (target === mobileMenuRef.current) {
setIsMobileMenuOpen(false);
}
}}
>
<FeatureCardContainer className="col text-sm divide-y divide-foreground/10 gap-0 mt-12 py-4">
{LINKS?.map((link) => {
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground text-lg font-semibold p-4 px-0"
onClick={() => setIsMobileMenuOpen(false)}
>
{link.text}
</Link>
);
})}
</FeatureCardContainer>
<div className="row gap-2 absolute top-3 right-4 items-center">
<ThemeToggle className="flex!" />
<Button
size="icon"
variant="outline"
onClick={() => setIsMobileMenuOpen(false)}
>
<XIcon className="size-4" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</nav>
);
};
export default Navbar;
function ThemeToggle({ className }: { className?: string }) {
const theme = useTheme();
return (
<Button
size="icon"
variant="naked"
onClick={() => theme.setTheme(theme.theme === 'dark' ? 'light' : 'dark')}
className={cn(
'relative overflow-hidden size-8 cursor-pointer hidden md:inline',
className,
)}
suppressHydrationWarning
>
<AnimatePresence mode="wait" initial={false}>
{theme.theme === 'dark' ||
(!theme.theme && theme.resolvedTheme === 'dark') ? (
<motion.div
key="moon"
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 90, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<MoonIcon className="size-4" suppressHydrationWarning />
</motion.div>
) : (
<motion.div
key="sun"
initial={{ rotate: 90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: -90, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<SunIcon className="size-4" suppressHydrationWarning />
</motion.div>
)}
</AnimatePresence>
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
export function Perks({
perks,
className,
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
return (
<ul className={cn('grid grid-cols-2 gap-2', className)}>
{perks.map((perk) => (
<li key={perk.text} className="text-sm text-muted-foreground">
<perk.icon className="size-4 inline-block mr-2 relative -top-px" />
{perk.text}
</li>
))}
</ul>
);
}

View 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>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import NumberFlow from '@number-flow/react';
import { cn } from '@/lib/utils';
import { PRICING } from '@openpanel/payments/prices';
import { useState } from 'react';
import { Slider } from './ui/slider';
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 per month`
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
}
onValueChange={(value) => setIndex(value[0])}
/>
{match ? (
<div>
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}}
locales={'en-US'}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
</div>
<span
className={cn(
'text-sm text-muted-foreground italic opacity-100',
match.price === 0 && 'opacity-0',
)}
>
+ VAT if applicable
</span>
</div>
) : (
<div className="text-lg">
Contact us at{' '}
<a className="underline" href="mailto:hello@openpanel.dev">
hello@openpanel.dev
</a>{' '}
to get a custom quote.
</div>
)}
</>
);
}

View File

@@ -0,0 +1,81 @@
import { cn } from '@/lib/utils';
export function Section({
children,
className,
id,
...props
}: {
children: React.ReactNode;
className?: string;
id?: string;
}) {
return (
<section id={id} className={cn('my-32 col', className)} {...props}>
{children}
</section>
);
}
const variants = {
default: 'text-3xl md:text-5xl font-semibold',
sm: 'text-3xl md:text-4xl font-semibold',
};
export function SectionHeader({
label,
title,
description,
className,
align,
as = 'h2',
variant = 'default',
}: {
label?: string;
title: string | React.ReactNode;
description?: string;
className?: string;
align?: 'center' | 'left';
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
variant?: keyof typeof variants;
}) {
const Heading = as;
return (
<div
className={cn(
'col gap-4',
align === 'center'
? 'center-center text-center'
: 'items-start text-left',
className,
)}
>
{label && <SectionLabel>{label}</SectionLabel>}
<Heading className={cn(variants[variant], 'max-w-3xl leading-tight')}>
{title}
</Heading>
{description && (
<p className={cn('text-muted-foreground max-w-3xl')}>{description}</p>
)}
</div>
);
}
export function SectionLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={cn(
'text-xs uppercase tracking-wider text-muted-foreground font-medium',
className,
)}
>
{children}
</span>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
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-sm">{content}</p>;
}
if (Array.isArray(content) && typeof content[0] === 'string') {
return content.map((line) => (
<p key={line} className="text-sm">
{line}
</p>
));
}
return <div className="text-sm">{content}</div>;
};
return (
<div className="border rounded-3xl p-8 col gap-4 bg-background-light">
<div className="row gap-4">
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
{avatarUrl && (
<Image src={avatarUrl} alt={name} width={48} height={48} />
)}
</div>
<div className="col gap-4">
<div className="col gap-2">
<div className="">
<span className="font-medium">{name}</span>
{verified && (
<div className="relative inline-block top-0.5 ml-1">
<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">
<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-blue-500" />
{/* <span>{retweets}</span> */}
</div>
<div className="row gap-2">
<HeartIcon className="transition-all size-4 fill-background hover:fill-rose-500 hover:text-rose-500" />
{/* <span>{likes}</span> */}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
import { FeatureCardBackground } from '../feature-card';
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 not-prose">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'group relative overflow-hidden flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180 cursor-pointer',
className,
)}
{...props}
>
<FeatureCardBackground />
{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 transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-muted-foreground"
{...props}
>
<div
className={cn(
'pb-4 pt-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className,
)}
>
{children}
</div>
</AccordionPrimitive.Content>
);
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

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

View File

@@ -0,0 +1,69 @@
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false);
React.useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
}, [query]);
return matches;
}
const Slider = ({
ref,
className,
tooltip,
...props
}: {
ref?: any;
className?: string;
tooltip?: string;
value: number[];
max: number;
step: number;
onValueChange: (value: number[]) => void;
}) => {
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<>
{isMobile && (
<div className="text-sm text-muted-foreground mb-4">{tooltip}</div>
)}
<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 && !isMobile ? (
<Tooltip open disableHoverableContent>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={10}
className="rounded-full bg-black text-white/70 py-1 text-xs border-white/30"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
)}
</SliderPrimitive.Root>
</>
);
};
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,51 @@
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 TooltipPortal = TooltipPrimitive.Portal;
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 };
export const Tooltiper = ({
children,
content,
delayDuration = 0,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
delayDuration?: number;
}) => (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPortal>
<TooltipContent {...props}>{content}</TooltipContent>
</TooltipPortal>
</Tooltip>
);

View 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)

View File

@@ -0,0 +1,73 @@
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { FeatureCardContainer } from './feature-card';
interface WindowImageProps {
src?: string;
srcDark?: string;
srcLight?: string;
alt: string;
className?: string;
caption?: string;
}
export function WindowImage({
src,
srcDark,
srcLight,
alt,
caption,
className,
}: WindowImageProps) {
// If src is provided, use it for both (backward compatibility)
// Otherwise, use srcDark and srcLight
const darkSrc = srcDark || src;
const lightSrc = srcLight || src;
if (!darkSrc || !lightSrc) {
throw new Error(
'WindowImage requires either src or both srcDark and srcLight',
);
}
return (
<FeatureCardContainer
className={cn([
'overflow-hidden rounded-lg border border-border bg-background shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
className,
])}
>
<div className="rounded-lg overflow-hidden p-2 bg-card/80 border col gap-2 relative">
{/* Window controls */}
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="size-2 rounded-full bg-red-500" />
<div className="size-2 rounded-full bg-yellow-500" />
<div className="size-2 rounded-full bg-green-500" />
</div>
</div>
<div className="relative w-full border rounded-md overflow-hidden">
<Image
src={darkSrc}
alt={alt}
width={1200}
height={800}
className="hidden dark:block w-full h-auto"
/>
<Image
src={lightSrc}
alt={alt}
width={1200}
height={800}
className="dark:hidden w-full h-auto"
/>
</div>
</div>
{caption && (
<figcaption className="text-center text-sm text-muted-foreground max-w-lg mx-auto">
{caption}
</figcaption>
)}
</FeatureCardContainer>
);
}

View File

@@ -0,0 +1,213 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export interface CompareSeo {
title: string;
description: string;
noindex?: boolean;
}
export interface CompareCta {
label: string;
href: string;
}
export interface CompareHero {
heading: string;
subheading: string;
badges: string[];
}
export interface CompareCompetitor {
name: string;
logo: string;
url: string;
short_description: string;
founded?: number;
headquarters?: string;
}
export interface CompareSummary {
title: string;
intro: string;
one_liner: string;
best_for_openpanel: string[];
best_for_competitor: string[];
}
export interface CompareHighlight {
label: string;
openpanel: string;
competitor: string;
}
export interface CompareHighlights {
title: string;
intro: string;
items: CompareHighlight[];
}
export interface CompareFeature {
name: string;
openpanel: boolean | string;
competitor: boolean | string;
notes?: string | null;
}
export interface CompareFeatureGroup {
group: string;
features: CompareFeature[];
}
export interface CompareFeatureComparison {
title: string;
intro: string;
groups: CompareFeatureGroup[];
}
export interface ComparePricing {
title: string;
intro: string;
openpanel: {
model: string;
description: string;
};
competitor: {
model: string;
description: string;
free_tier?: string;
pricing_url?: string;
};
}
export interface CompareTrust {
data_processing: string;
data_location: string;
self_hosting: boolean;
}
export interface CompareTrustCompliance {
title: string;
intro: string;
openpanel: CompareTrust;
competitor: CompareTrust;
}
export interface CompareUseCase {
title: string;
description: string;
icon?: string;
}
export interface CompareUseCases {
title: string;
intro: string;
items: CompareUseCase[];
}
export interface CompareFaq {
question: string;
answer: string;
}
export interface CompareFaqs {
title: string;
intro: string;
items: CompareFaq[];
}
export interface CompareBenefitsSection {
label?: string;
title: string;
description: string;
cta?: CompareCta;
benefits: string[];
}
export interface CompareTechnicalItem {
label: string;
openpanel: string | string[];
competitor: string | string[];
notes?: string | null;
}
export interface CompareTechnicalComparison {
title: string;
intro: string;
items: CompareTechnicalItem[];
}
export interface CompareMigrationStep {
title: string;
description: string;
}
export interface CompareMigration {
title: string;
intro: string;
difficulty: string;
estimated_time: string;
steps: CompareMigrationStep[];
sdk_compatibility: {
similar_api: boolean;
notes: string;
};
historical_data: {
can_import: boolean;
notes: string;
};
}
export interface CompareData {
url: string;
slug: string;
page_type: 'alternative' | 'vs';
seo: CompareSeo;
hero: CompareHero;
competitor: CompareCompetitor;
summary_comparison: CompareSummary;
highlights: CompareHighlights;
feature_comparison: CompareFeatureComparison;
technical_comparison?: CompareTechnicalComparison;
pricing: ComparePricing;
migration?: CompareMigration;
trust_and_compliance?: CompareTrustCompliance;
use_cases: CompareUseCases;
faqs: CompareFaqs;
benefits_section?: CompareBenefitsSection;
ctas: {
primary: CompareCta;
secondary: CompareCta;
};
}
const contentDir = join(process.cwd(), 'content', 'compare');
export async function getCompareData(
slug: string,
): Promise<CompareData | null> {
try {
const filePath = join(contentDir, `${slug}.json`);
const fileContents = readFileSync(filePath, 'utf8');
const data = JSON.parse(fileContents) as CompareData;
return {
...data,
url: `/compare/${slug}`,
};
} catch (error) {
console.error(`Error loading compare data for ${slug}:`, error);
return null;
}
}
export async function getAllCompareSlugs(): Promise<string[]> {
try {
const files = readdirSync(contentDir);
return files
.filter((file) => file.endsWith('.json'))
.map((file) => file.replace('.json', ''));
} catch (error) {
console.error('Error reading compare directory:', error);
return [];
}
}

View 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;
}

View File

@@ -0,0 +1,10 @@
export async function getGithubRepoInfo() {
try {
const res = await fetch(
'https://api.github.com/repos/Openpanel-dev/openpanel',
);
return res.json();
} catch (e) {
return null;
}
}

View File

@@ -0,0 +1,39 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const siteName = 'OpenPanel';
export const baseUrl =
process.env.NODE_ENV === 'production'
? 'https://openpanel.dev'
: 'http://localhost:3000';
export const url = (path: string) => {
if (path.startsWith('http')) {
return path;
}
return `${baseUrl}${path}`;
};
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: siteName,
},
links: [],
};
}
export const authors = [
{
name: 'OpenPanel Team',
url: 'https://openpanel.com',
},
{
name: 'Carl-Gerhard Lindesvärd',
url: 'https://openpanel.com',
image: '/twitter-carl.jpg',
},
];
export const getAuthor = (author?: string) => {
return authors.find((a) => a.name === author)!;
};

View File

@@ -0,0 +1,84 @@
import type { Metadata } from 'next';
import { url as baseUrl } from './layout.shared';
const siteName = 'OpenPanel';
const defaultDescription =
'OpenPanel is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.';
const defaultImage = baseUrl('/ogimage.png');
export function getOgImageUrl(url: string): string {
return `/og/${url.replace(baseUrl('/'), '/')}`;
}
export function getRootMetadata(): Metadata {
return getRawMetadata({
url: baseUrl('/'),
title: `${siteName} | An open-source alternative to Mixpanel`,
description: defaultDescription,
image: defaultImage,
});
}
export function getPageMetadata({
url,
title,
description,
image,
}: {
url: string;
title: string;
description: string;
image?: string;
}): Metadata {
return getRawMetadata({
url,
title: `${title} | ${siteName}`,
description,
image: image ?? getOgImageUrl(url),
});
}
export function getRawMetadata(
{
url,
title,
description,
image,
}: { url: string; title: string; description: string; image: string },
meta: Metadata = {},
): Metadata {
return {
title,
description,
alternates: {
canonical: baseUrl(url),
},
icons: {
apple: '/apple-touch-icon.png',
icon: '/favicon.ico',
},
manifest: '/site.webmanifest',
openGraph: {
title,
description,
siteName: siteName,
url: baseUrl(url),
type: 'website',
images: [
{
url: image,
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
...meta,
};
}

View File

@@ -0,0 +1,73 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
articleCollection,
docs,
pageCollection,
} from 'fumadocs-mdx:collections/server';
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import type { CompareData } from './compare';
import { url } from './layout.shared';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
});
export const articleSource = loader({
baseUrl: '/articles',
source: toFumadocsSource(articleCollection, []),
plugins: [lucideIconsPlugin()],
});
export const pageSource = loader({
baseUrl: '/',
source: toFumadocsSource(pageCollection, []),
});
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
return `# ${page.data.title}
${processed}`;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const contentDir = path.join(__dirname, '../../content/compare');
const files = fs
.readdirSync(contentDir)
.filter((file) => file.endsWith('.json'));
export const compareSource: CompareData[] = files
.map((file) => {
const filePath = path.join(contentDir, file);
const fileContents = fs.readFileSync(filePath, 'utf8');
try {
return JSON.parse(fileContents) as CompareData;
} catch (error) {
console.error(`Error parsing compare data for ${file}:`, error);
return null;
}
})
.flatMap((item) => (item ? [item] : []))
.map((item) => ({
...item,
url: `/compare/${item.slug}`,
}));

View File

@@ -0,0 +1,16 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatEventsCount = (value: number) => {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K`;
}
return value.toString();
};

View File

@@ -0,0 +1,33 @@
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import * as FilesComponents from 'fumadocs-ui/components/files';
import * as TabsComponents from 'fumadocs-ui/components/tabs';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import * as icons from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...(icons as unknown as MDXComponents),
...defaultMdxComponents,
...TabsComponents,
...FilesComponents,
Accordion,
Accordions,
...components,
} satisfies MDXComponents;
}
declare module 'mdx/types.js' {
// Augment the MDX types to make it understand React.
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
type Element = React.JSX.Element;
type ElementClass = React.JSX.ElementClass;
type ElementType = React.JSX.ElementType;
type IntrinsicElements = React.JSX.IntrinsicElements;
}
}
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}