feat: new public website
This commit is contained in:
82
apps/public/src/app/(content)/[...pages]/page.tsx
Normal file
82
apps/public/src/app/(content)/[...pages]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/public/src/app/(content)/articles/[articleSlug]/page.tsx
Normal file
190
apps/public/src/app/(content)/articles/[articleSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/public/src/app/(content)/articles/page.tsx
Normal file
54
apps/public/src/app/(content)/articles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
224
apps/public/src/app/(content)/compare/[slug]/page.tsx
Normal file
224
apps/public/src/app/(content)/compare/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
73
apps/public/src/app/(content)/compare/page.tsx
Normal file
73
apps/public/src/app/(content)/compare/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/public/src/app/(content)/layout.tsx
Normal file
17
apps/public/src/app/(content)/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
apps/public/src/app/(content)/pricing/page.tsx
Normal file
109
apps/public/src/app/(content)/pricing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
apps/public/src/app/(content)/supporter/page.tsx
Normal file
215
apps/public/src/app/(content)/supporter/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user