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