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>
|
||||
);
|
||||
}
|
||||
70
apps/public/src/app/(home)/_sections/analytics-insights.tsx
Normal file
70
apps/public/src/app/(home)/_sections/analytics-insights.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader, SectionLabel } from '@/components/section';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
DollarSignIcon,
|
||||
GlobeIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import { ProductAnalyticsIllustration } from './illustrations/product-analytics';
|
||||
import { WebAnalyticsIllustration } from './illustrations/web-analytics';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Revenue tracking',
|
||||
description:
|
||||
'Track revenue from your payments and get insights into your revenue sources.',
|
||||
icon: DollarSignIcon,
|
||||
},
|
||||
{
|
||||
title: 'Profiles & Sessions',
|
||||
description:
|
||||
'Track individual users and their complete journey across your platform.',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
title: 'Event Tracking',
|
||||
description:
|
||||
'Capture every important interaction with flexible event tracking.',
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
];
|
||||
|
||||
export function AnalyticsInsights() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
label="ANALYTICS & INSIGHTS"
|
||||
title="See the full picture of your users and product performance"
|
||||
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking."
|
||||
className="mb-16"
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Web Analytics"
|
||||
description="Understand your website performance with privacy-first analytics and clear, actionable insights."
|
||||
illustration={<WebAnalyticsIllustration />}
|
||||
className="px-0 **:data-content:px-6"
|
||||
/>
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Product Analytics"
|
||||
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends."
|
||||
illustration={<ProductAnalyticsIllustration />}
|
||||
className="px-0 **:data-content:px-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
171
apps/public/src/app/(home)/_sections/collaboration-chart.tsx
Normal file
171
apps/public/src/app/(home)/_sections/collaboration-chart.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { FeatureCardContainer } from '@/components/feature-card';
|
||||
import { MoreVerticalIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
// Sample data for the last 7 days
|
||||
const data = [
|
||||
{ day: 'Mon', visitors: 1200, revenue: 1250 },
|
||||
{ day: 'Tue', visitors: 1450, revenue: 1890 },
|
||||
{ day: 'Wed', visitors: 1320, revenue: 1520 },
|
||||
{ day: 'Thu', visitors: 1580, revenue: 2100 },
|
||||
{ day: 'Fri', visitors: 1420, revenue: 1750 },
|
||||
{ day: 'Sat', visitors: 1180, revenue: 1100 },
|
||||
{ day: 'Sun', visitors: 1250, revenue: 1380 },
|
||||
];
|
||||
|
||||
// Custom tooltip component
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const visitors =
|
||||
payload.find((p: any) => p.dataKey === 'visitors')?.value || 0;
|
||||
const revenue =
|
||||
payload.find((p: any) => p.dataKey === 'revenue')?.value || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-3 shadow-lg min-w-[200px]">
|
||||
<div className="text-sm font-semibold mb-2">{label}</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1 flex-1">
|
||||
<div className="row gap-2 items-center flex-1">
|
||||
<div className="h-6 bg-foreground w-1 rounded-full" />
|
||||
<div className="font-medium row items-center gap-2 justify-between flex-1">
|
||||
<span>Visitors</span> <span>{visitors.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row gap-2 items-center flex-1">
|
||||
<div className="h-6 bg-emerald-500 w-1 rounded-full" />
|
||||
<div className="font-medium row items-center gap-2 justify-between flex-1">
|
||||
<span>Revenue</span> <span>${revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function CollaborationChart() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(1); // Default to Tue (index 1)
|
||||
|
||||
// Calculate metrics from active point or default
|
||||
const activeData = useMemo(() => {
|
||||
return activeIndex !== null ? data[activeIndex] : data[1];
|
||||
}, [activeIndex]);
|
||||
|
||||
const totalVisitors = activeData.visitors;
|
||||
const totalRevenue = activeData.revenue;
|
||||
|
||||
return (
|
||||
<FeatureCardContainer className="col gap-4 h-full">
|
||||
{/* Header */}
|
||||
<div className="row items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">Product page views</h3>
|
||||
<p className="text-sm text-muted-foreground">Last 7 days</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1 min-h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
onMouseMove={(state) => {
|
||||
if (state?.activeTooltipIndex !== undefined) {
|
||||
setActiveIndex(state.activeTooltipIndex);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setActiveIndex(null)}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
domain={[0, 2400]}
|
||||
hide
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={false} />
|
||||
{/* Revenue bars */}
|
||||
<Bar yAxisId="right" dataKey="revenue" radius={4}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${entry.day}`}
|
||||
className={
|
||||
activeIndex === index
|
||||
? 'fill-emerald-500' // Lighter green on hover
|
||||
: 'fill-foreground/30' // Default green
|
||||
}
|
||||
style={{ transition: 'fill 0.2s ease' }}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="visitors"
|
||||
strokeWidth={2}
|
||||
stroke="var(--foreground)"
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 center-center">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold font-mono">
|
||||
{totalVisitors.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Visitors</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold font-mono text-emerald-500">
|
||||
${totalRevenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Revenue</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
66
apps/public/src/app/(home)/_sections/collaboration.tsx
Normal file
66
apps/public/src/app/(home)/_sections/collaboration.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
DollarSignIcon,
|
||||
LayoutDashboardIcon,
|
||||
RocketIcon,
|
||||
WorkflowIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { CollaborationChart } from './collaboration-chart';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Visualize your data',
|
||||
description:
|
||||
'See your data in a visual way. You can create advanced reports and more to understand',
|
||||
icon: ChartBarIcon,
|
||||
},
|
||||
{
|
||||
title: 'Share & Collaborate',
|
||||
description:
|
||||
'Build interactive dashboards and share insights with your team. Export reports, set up notifications, and keep everyone aligned.',
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
description:
|
||||
'Get notified when new events are created, or forward specific events to your own systems with our east to use integrations.',
|
||||
icon: WorkflowIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function Collaboration() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-16">
|
||||
<CollaborationChart />
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Turn data into actionable insights"
|
||||
description="Build interactive dashboards, share insights with your team, and make data-driven decisions faster. OpenPanel helps you understand not just what's happening, but why."
|
||||
/>
|
||||
|
||||
<GetStartedButton className="mt-6" />
|
||||
|
||||
<div className="col gap-6 mt-16">
|
||||
{features.map((feature) => (
|
||||
<div className="col gap-2" key={feature.title}>
|
||||
<h3 className="font-semibold">
|
||||
<feature.icon className="size-6 inline-block mr-2 relative -top-0.5" />
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
81
apps/public/src/app/(home)/_sections/cta-banner.tsx
Normal file
81
apps/public/src/app/(home)/_sections/cta-banner.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
function Svg({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="409"
|
||||
height="539"
|
||||
viewBox="0 0 409 539"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('text-foreground', className)}
|
||||
>
|
||||
<path
|
||||
d="M222.146 483.444C332.361 429.581 378.043 296.569 324.18 186.354C270.317 76.1395 137.306 30.4572 27.0911 84.3201"
|
||||
stroke="url(#paint0_linear_552_3808)"
|
||||
strokeWidth="123.399"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_552_3808"
|
||||
x1="324.18"
|
||||
y1="186.354"
|
||||
x2="161.365"
|
||||
y2="265.924"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CtaBanner({
|
||||
title = (
|
||||
<>
|
||||
Ready to understand your users better?
|
||||
<br />
|
||||
Start tracking in minutes
|
||||
</>
|
||||
),
|
||||
description = 'Join thousands of companies using OpenPanel. Free 30-day trial, no credit card required. Self-host for free or use our cloud.',
|
||||
ctaText,
|
||||
ctaLink,
|
||||
}: {
|
||||
title?: string | React.ReactNode;
|
||||
description?: string;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="container">
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden border rounded-3xl py-16 px-4 md:px-16',
|
||||
)}
|
||||
>
|
||||
<div className="size-px absolute left-12 bottom-12 rounded-full shadow-[0_0_250px_80px_var(--color-foreground)]" />
|
||||
<div className="size-px absolute right-12 top-12 rounded-full shadow-[0_0_250px_80px_var(--color-foreground)]" />
|
||||
<Svg className="absolute left-0 bottom-0 -translate-x-1/2 translate-y-1/2 max-md:scale-50 opacity-50" />
|
||||
<Svg className="absolute right-0 top-0 translate-x-1/2 -translate-y-1/2 rotate-105 max-md:scale-50 scale-75 opacity-50" />
|
||||
|
||||
<div className="absolute inset-0 bg-linear-to-br from-foreground/5 via-transparent to-foreground/5" />
|
||||
<div className="container relative z-10 col gap-6 center-center max-w-3xl">
|
||||
<h2 className="text-4xl md:text-4xl font-semibold text-center">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
<GetStartedButton className="mt-4" text={ctaText} href={ctaLink} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/public/src/app/(home)/_sections/data-privacy.tsx
Normal file
67
apps/public/src/app/(home)/_sections/data-privacy.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { BoltIcon, GithubIcon, ServerIcon } from 'lucide-react';
|
||||
import { DataOwnershipIllustration } from './illustrations/data-ownership';
|
||||
import { PrivacyIllustration } from './illustrations/privacy';
|
||||
|
||||
const secondaryFeatures = [
|
||||
{
|
||||
title: 'Open Source',
|
||||
description:
|
||||
'Full transparency. Audit the code, contribute, fork it, or self-host without lock-in.',
|
||||
icon: GithubIcon,
|
||||
},
|
||||
{
|
||||
title: 'Self-hosting',
|
||||
description:
|
||||
'Deploy OpenPanel anywhere - your server, your cloud, or locally. Full flexibility.',
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
title: 'Lightweight & Fast',
|
||||
description:
|
||||
"A tiny, high-performance tracker that won't slow down your site.",
|
||||
icon: BoltIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function DataPrivacy() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
Built for Control,
|
||||
<br />
|
||||
Transparency & Trust
|
||||
</>
|
||||
}
|
||||
description="OpenPanel gives you analytics on your terms - privacy-friendly, open-source, and fully self-hostable. Every part of the platform is designed to put you in control of your data while delivering fast, reliable insights without compromising user trust."
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6 mt-16">
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Privacy-first"
|
||||
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust."
|
||||
illustration={<PrivacyIllustration />}
|
||||
/>
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Data Ownership"
|
||||
description="You own your data - no vendors, no sharing, no hidden processing. Store analytics on your own infrastructure and stay in full control."
|
||||
illustration={<DataOwnershipIllustration />}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{secondaryFeatures.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
102
apps/public/src/app/(home)/_sections/faq.tsx
Normal file
102
apps/public/src/app/(home)/_sections/faq.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { FaqItem, Faqs } from '@/components/faq';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import Script from 'next/script';
|
||||
|
||||
const faqData = [
|
||||
{
|
||||
question: 'Does OpenPanel have a free tier?',
|
||||
answer:
|
||||
'For our Cloud plan we offer a 30 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan. OpenPanel is also open-source and you can self-host it for free!\n\nWhy does OpenPanel not have a free tier? We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
|
||||
},
|
||||
{
|
||||
question: 'Is everything really unlimited?',
|
||||
answer:
|
||||
'Yes! With OpenPanel, you get unlimited websites/apps, unlimited users, unlimited dashboards, unlimited charts, and unlimited tracked profiles.\n\nThe only limit is the number of events you track per month, which you choose based on your needs.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between web and product analytics?',
|
||||
answer:
|
||||
'Web analytics focuses on website traffic, page views, and visitor behavior. Product analytics goes deeper, tracking user interactions, events, and product usage patterns.\n\nOpenPanel combines both, giving you a complete view of your users and product performance.',
|
||||
},
|
||||
{
|
||||
question: 'Do I need to modify my code to use OpenPanel?',
|
||||
answer:
|
||||
'OpenPanel offers multiple SDKs and integration options. For most frameworks, you can get started with just a few lines of code.\n\nWe provide SDKs for React, Next.js, Vue, Astro, and many more. Check our documentation for your specific framework.',
|
||||
},
|
||||
{
|
||||
question: 'Is my data GDPR compliant?',
|
||||
answer:
|
||||
"Yes! OpenPanel is designed with privacy in mind. We use cookie-less tracking, don't collect personal data without consent, and give you full control over your data.\n\nYou can self-host to ensure complete data sovereignty.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to other analytics tools?',
|
||||
answer:
|
||||
'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare).',
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Mixpanel?',
|
||||
answer:
|
||||
"OpenPanel offers similar powerful product analytics features as Mixpanel, but with the added benefits of being open-source, more affordable, and including web analytics capabilities.\n\nYou get Mixpanel's power with Plausible's simplicity.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Plausible?',
|
||||
answer:
|
||||
"OpenPanel shares Plausible's privacy-first approach and simplicity, but adds powerful product analytics capabilities.\n\nWhile Plausible focuses on web analytics, OpenPanel combines both web and product analytics in one platform.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Google Analytics?',
|
||||
answer:
|
||||
"OpenPanel is a privacy-first alternative to Google Analytics. Unlike GA, we don't use cookies, respect user privacy, and give you full control over your data.\n\nPlus, you get product analytics features that GA doesn't offer.",
|
||||
},
|
||||
{
|
||||
question: 'Can I export my data?',
|
||||
answer:
|
||||
'Absolutely! You own your data and can export it anytime. We have API endpoints to get all raw data that we have access to.\n\nIf you self-host, you have direct access and own all your data. For our cloud service, you can always reach out to us if you want a database dump of all your data—perfect if you want to move from cloud to self-hosting.\n\nWe have no lock-in whatsoever.',
|
||||
},
|
||||
{
|
||||
question: 'What kind of support do you offer?',
|
||||
answer:
|
||||
'We offer support through our documentation, GitHub issues, and Discord community. For paid plans, we provide email support.\n\nOur team is committed to helping you succeed with OpenPanel.',
|
||||
},
|
||||
];
|
||||
|
||||
export function Faq() {
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqData.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: q.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
title="FAQ"
|
||||
description="Some of the most common questions we get asked."
|
||||
/>
|
||||
<Faqs>
|
||||
{faqData.map((faq) => (
|
||||
<FaqItem key={faq.question} question={faq.question}>
|
||||
{faq.answer}
|
||||
</FaqItem>
|
||||
))}
|
||||
</Faqs>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
188
apps/public/src/app/(home)/_sections/hero.tsx
Normal file
188
apps/public/src/app/(home)/_sections/hero.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
import { Competition } from '@/components/competition';
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Perks } from '@/components/perks';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ChevronRightIcon,
|
||||
CookieIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
FlaskRoundIcon,
|
||||
GithubIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
const aspectRatio = 2946 / 1329;
|
||||
const width = 2346;
|
||||
const height = width / aspectRatio;
|
||||
|
||||
function HeroImage({ className }: { className?: string }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, x: 0 }}
|
||||
animate={
|
||||
isLoaded
|
||||
? { opacity: 0.5, scale: 1, x: 0 }
|
||||
: { opacity: 0, scale: 0.9, x: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 2,
|
||||
}}
|
||||
className={cn('absolute', className)}
|
||||
style={{
|
||||
left: `calc(50% - ${width / 2}px - 50px)`,
|
||||
top: -270,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/hero-dark.webp"
|
||||
alt="Hero"
|
||||
width={width}
|
||||
height={height}
|
||||
className="hidden dark:block"
|
||||
style={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height,
|
||||
}}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
<Image
|
||||
src="/hero-light.webp"
|
||||
alt="Hero"
|
||||
width={width}
|
||||
height={height}
|
||||
className="dark:hidden"
|
||||
style={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height,
|
||||
}}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<HeroContainer className="-mb-32 max-sm:**:data-children:pb-0">
|
||||
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
|
||||
<div className="col gap-4">
|
||||
{/* <div className="font-mono text-sm text-muted-foreground">
|
||||
TRUSTED BY 1,000+ COMPANIES • 4.7K GITHUB STARS
|
||||
</div> */}
|
||||
<h1 className="text-4xl md:text-5xl font-semibold leading-[1.1]">
|
||||
An open-source alternative to <Competition />
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines the
|
||||
power of Mixpanel with the ease of Plausible and one of the best
|
||||
Google Analytics replacements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-4">
|
||||
<GetStartedButton />
|
||||
<Button size="lg" variant="outline" asChild className="px-6">
|
||||
<Link
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
Test live demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Perks perks={perks} />
|
||||
</div>
|
||||
|
||||
<div className="col sm:w-1/2 relative group max-sm:px-4">
|
||||
<div
|
||||
className={cn([
|
||||
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
|
||||
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-64',
|
||||
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
|
||||
])}
|
||||
>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
{/* URL bar */}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background/20 rounded-md border border-border flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground flex-1">
|
||||
https://demo.openpanel.dev
|
||||
</span>
|
||||
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroContainer({
|
||||
children,
|
||||
className,
|
||||
divider = true,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
divider?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section
|
||||
className={cn('relative z-10', divider && 'overflow-hidden', className)}
|
||||
>
|
||||
<div className="absolute inset-0 w-screen overflow-x-clip">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<div
|
||||
className="container relative col sm:row py-44 max-sm:pt-32"
|
||||
data-children
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{divider && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-20 border-t border-border rounded-t-[3rem] md:rounded-t-[6rem] bg-background shadow-[0_0_100px_var(--background)]" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
|
||||
type IllustrationProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DataOwnershipIllustration({
|
||||
className = '',
|
||||
}: IllustrationProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Main layout */}
|
||||
<div className="relative grid aspect-2/1 grid-cols-5 gap-3">
|
||||
{/* Left: your server card */}
|
||||
<div
|
||||
className="
|
||||
col-span-3 rounded-2xl border border-border bg-card/80
|
||||
p-3 sm:p-4 shadow-xl backdrop-blur
|
||||
transition-all duration-300
|
||||
group-hover:-translate-y-1 group-hover:-translate-x-0.5
|
||||
"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-foreground">
|
||||
<span>Your server</span>
|
||||
<span className="flex items-center gap-1 rounded-full bg-card/80 px-2 py-0.5 text-[10px] text-blue-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
In control
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* "Server" visual */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground">Region</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
EU / Custom
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground">Retention</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Configurable
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* mini "database"/requests strip */}
|
||||
<div className="mt-1 rounded-xl border border-border bg-card/90 px-3 py-2 text-[11px] text-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Events stored</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
locally
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-blue-400/70" />
|
||||
<div className="h-1.5 flex-1 rounded-full bg-blue-400/40" />
|
||||
<div className="h-1.5 flex-1 rounded-full bg-blue-400/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 rounded-xl border border-border bg-card/90 px-3 py-2 text-[11px] text-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>CPU</span>
|
||||
<span className="text-[10px] text-muted-foreground">20%</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-blue-400/70" />
|
||||
<div className="h-1.5 flex-1 rounded-full bg-blue-400/40" />
|
||||
<div className="h-1.5 flex-1 rounded-full bg-blue-400/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: third-party contrast */}
|
||||
<div
|
||||
className="
|
||||
col-span-2 rounded-2xl border border-border/80 bg-card/40
|
||||
p-3 text-[11px] text-muted-foreground
|
||||
transition-all duration-300
|
||||
group-hover:translate-y-1 group-hover:translate-x-0.5 group-hover:opacity-70
|
||||
"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-2">or use our cloud</p>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-1 rounded-full bg-blue-400" />
|
||||
Zero server setup
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-1 rounded-full bg-blue-400" />
|
||||
Auto-scaling & backups
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-1 rounded-full bg-blue-400" />
|
||||
99.9% uptime
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-1 rounded-full bg-blue-400" />
|
||||
24/7 support
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-1 rounded-full bg-blue-400" />
|
||||
Export data anytime
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
|
||||
type IllustrationProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PrivacyIllustration({ className = '' }: IllustrationProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Floating cards */}
|
||||
<div className="relative aspect-3/2 md:aspect-2/1">
|
||||
{/* Back card */}
|
||||
<div
|
||||
className="
|
||||
absolute top-0 left-0 right-10 bottom-10 rounded-2xl border border-border/80 bg-card/70
|
||||
backdrop-blur-sm shadow-lg
|
||||
transition-all duration-300
|
||||
group-hover:-translate-y-1 group-hover:-rotate-2
|
||||
"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-3 text-xs text-muted-foreground">
|
||||
<span>Session duration</span>
|
||||
<span className="flex items-center gap-1">
|
||||
3m 12s
|
||||
<span className="text-[10px] text-blue-400">+8%</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Simple line chart */}
|
||||
<div className="mt-3 px-4">
|
||||
<svg
|
||||
viewBox="0 0 120 40"
|
||||
className="h-16 w-full text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
d="M2 32 L22 18 L40 24 L60 10 L78 16 L96 8 L118 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="opacity-60"
|
||||
/>
|
||||
<circle cx="118" cy="14" r="2.5" className="fill-blue-400" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Front card */}
|
||||
<div
|
||||
className="
|
||||
col
|
||||
absolute top-10 left-4 right-0 bottom-0 rounded-2xl border border-border/80
|
||||
bg-card shadow-xl
|
||||
transition-all duration-300
|
||||
group-hover:translate-y-1 group-hover:rotate-2
|
||||
"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-3 text-xs text-foreground">
|
||||
<span>Anonymous visitors</span>
|
||||
<span className="text-[10px] rounded-full bg-card px-2 py-0.5 text-muted-foreground">
|
||||
No cookies
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between px-4 pt-4 pb-3">
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground mb-1">
|
||||
Active now
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-foreground">128</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
<span>IP + UA hashed daily</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
<span>No fingerprinting</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "Sources" row */}
|
||||
<div className="mt-auto flex gap-2 border-t border-border px-3 py-2.5 text-[11px]">
|
||||
<div className="flex-1 rounded-xl bg-card/90 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Direct</span>
|
||||
<span className="text-foreground">42%</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl bg-card/90 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Organic</span>
|
||||
<span className="text-foreground">58%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponsiveFunnel } from '@nivo/funnel';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { AnimatePresence, motion, useSpring } from 'framer-motion';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function useFunnelSteps() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return [
|
||||
{
|
||||
id: 'Visitors',
|
||||
label: 'Visitors',
|
||||
value: 10000,
|
||||
percentage: 100,
|
||||
color: resolvedTheme === 'dark' ? '#333' : '#888',
|
||||
},
|
||||
{
|
||||
id: 'Add to cart',
|
||||
label: 'Add to cart',
|
||||
value: 7000,
|
||||
percentage: 32,
|
||||
color: resolvedTheme === 'dark' ? '#222' : '#999',
|
||||
},
|
||||
{
|
||||
id: 'Checkout',
|
||||
label: 'Checkout',
|
||||
value: 5000,
|
||||
percentage: 8.9,
|
||||
color: resolvedTheme === 'dark' ? '#111' : '#e1e1e1',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ProductAnalyticsIllustration() {
|
||||
return (
|
||||
<div className="aspect-video">
|
||||
<FunnelVisualization />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PartLabel = ({ part }: { part: any }) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return (
|
||||
<g transform={`translate(${part.x}, ${part.y})`}>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{
|
||||
fill: resolvedTheme === 'dark' ? '#fff' : '#000',
|
||||
pointerEvents: 'none',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{part.data.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
function Labels(props: any) {
|
||||
return props.parts.map((part: any) => (
|
||||
<PartLabel key={part.data.id} part={part} />
|
||||
));
|
||||
}
|
||||
|
||||
function FunnelVisualization() {
|
||||
const funnelSteps = useFunnelSteps();
|
||||
const colors = funnelSteps.map((stage) => stage.color);
|
||||
const nivoData = funnelSteps.map((stage) => ({
|
||||
id: stage.id,
|
||||
value: stage.value,
|
||||
label: stage.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ResponsiveFunnel
|
||||
data={nivoData}
|
||||
margin={{ top: 20, right: 0, bottom: 20, left: 0 }}
|
||||
direction="horizontal"
|
||||
shapeBlending={0.6}
|
||||
colors={colors}
|
||||
enableBeforeSeparators={false}
|
||||
enableAfterSeparators={false}
|
||||
beforeSeparatorLength={0}
|
||||
afterSeparatorLength={0}
|
||||
afterSeparatorOffset={0}
|
||||
beforeSeparatorOffset={0}
|
||||
currentPartSizeExtension={5}
|
||||
borderWidth={20}
|
||||
currentBorderWidth={15}
|
||||
tooltip={() => null}
|
||||
layers={['parts', Labels]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
import { SimpleChart } from '@/components/simple-chart';
|
||||
import { cn } from '@/lib/utils';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowUpIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const TRAFFIC_SOURCES = [
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
|
||||
name: 'Google',
|
||||
percentage: 49,
|
||||
value: 2039,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
|
||||
name: 'Instagram',
|
||||
percentage: 23,
|
||||
value: 920,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
|
||||
name: 'Facebook',
|
||||
percentage: 18,
|
||||
value: 750,
|
||||
},
|
||||
{
|
||||
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
|
||||
name: 'Twitter',
|
||||
percentage: 10,
|
||||
value: 412,
|
||||
},
|
||||
];
|
||||
|
||||
const COUNTRIES = [
|
||||
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
|
||||
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
|
||||
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
|
||||
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
|
||||
];
|
||||
|
||||
export function WebAnalyticsIllustration() {
|
||||
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-12 group aspect-video">
|
||||
<div className="relative h-full col">
|
||||
<MetricCard
|
||||
title="Session duration"
|
||||
value="3m 23s"
|
||||
change="3%"
|
||||
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Bounce rate"
|
||||
value="46%"
|
||||
change="3%"
|
||||
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
|
||||
color="var(--foreground)"
|
||||
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
|
||||
/>
|
||||
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
|
||||
<BarCell
|
||||
{...TRAFFIC_SOURCES[currentSourceIndex]}
|
||||
className="group-hover:scale-105 transition-all duration-300"
|
||||
/>
|
||||
<BarCell
|
||||
{...TRAFFIC_SOURCES[
|
||||
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
|
||||
]}
|
||||
className="group-hover:scale-105 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
chartPoints,
|
||||
color,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
chartPoints: number[];
|
||||
color: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
|
||||
<div className="row items-end justify-between">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm">{title}</div>
|
||||
<div className="text-2xl font-semibold font-mono">{value}</div>
|
||||
</div>
|
||||
<div className="row gap-2 items-center font-mono font-medium">
|
||||
<ArrowUpIcon className="size-3" strokeWidth={3} />
|
||||
<div>{change}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleChart
|
||||
width={400}
|
||||
height={30}
|
||||
points={chartPoints}
|
||||
strokeColor={color}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarCell({
|
||||
icon,
|
||||
name,
|
||||
percentage,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: string;
|
||||
name: string;
|
||||
percentage: number;
|
||||
value: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative row justify-between ">
|
||||
<div className="row gap-2 items-center font-medium text-sm">
|
||||
{icon.startsWith('http') ? (
|
||||
<Image
|
||||
alt="serie icon"
|
||||
className="max-h-4 rounded-[2px] object-contain"
|
||||
src={icon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-2xl">{icon}</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={name}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{name}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="row gap-3 font-mono text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<NumberFlow value={percentage} />%
|
||||
</span>
|
||||
<NumberFlow value={value} locales={'en-US'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
apps/public/src/app/(home)/_sections/pricing.tsx
Normal file
119
apps/public/src/app/(home)/_sections/pricing.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { PRICING } from '@openpanel/payments/prices';
|
||||
|
||||
import { CheckIcon, StarIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { formatEventsCount } from '@/lib/utils';
|
||||
|
||||
const features = [
|
||||
'Unlimited websites or apps',
|
||||
'Unlimited users',
|
||||
'Unlimited dashboards',
|
||||
'Unlimited charts',
|
||||
'Unlimited tracked profiles',
|
||||
'Yes, we have no limits or hidden costs',
|
||||
];
|
||||
|
||||
export function Pricing() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(2);
|
||||
const selected = PRICING[selectedIndex];
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
<div className="col md:row gap-16">
|
||||
<div className="w-full md:w-1/3 min-w-sm col gap-4 border rounded-3xl p-6 bg-linear-to-b from-card to-background">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how many events you'll track this month
|
||||
</p>
|
||||
<div className="row flex-wrap gap-2">
|
||||
{PRICING.map((tier, index) => (
|
||||
<Button
|
||||
key={tier.price}
|
||||
variant={selectedIndex === index ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-full relative px-4 border')}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
{tier.popular && <StarIcon className="size-4" />}
|
||||
{formatEventsCount(tier.events)}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={selectedIndex === -1 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-full relative px-4 border')}
|
||||
onClick={() => setSelectedIndex(-1)}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col items-baseline mt-8 md:mt-auto w-full">
|
||||
{selected ? (
|
||||
<>
|
||||
<NumberFlow
|
||||
className="text-5xl font-bold"
|
||||
value={selected.price}
|
||||
format={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}}
|
||||
locales={'en-US'}
|
||||
/>
|
||||
<div className="row justify-between w-full">
|
||||
<span className="text-muted-foreground/80 text-sm -mt-2">
|
||||
Per month
|
||||
</span>
|
||||
<span className="text-muted-foreground/80 text-sm -mt-2">
|
||||
+ VAT if applicable
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-lg">
|
||||
Contact us at{' '}
|
||||
<a className="underline" href="mailto:hello@openpanel.dev">
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
to get a custom quote.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col gap-8 justify-center flex-1 shrink-0">
|
||||
<div className="col gap-4">
|
||||
<SectionHeader
|
||||
title="Simple, transparent pricing"
|
||||
description="Pay only for what you use. Choose your event volume - everything else is unlimited. No surprises, no hidden fees."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="col gap-2">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="row gap-2 items-start text-sm">
|
||||
<CheckIcon className="size-4 shrink-0 mt-0.5" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<GetStartedButton className="w-fit" />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All features are included upfront - no hidden costs. You choose how
|
||||
many events to track each month.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
30
apps/public/src/app/(home)/_sections/sdks.tsx
Normal file
30
apps/public/src/app/(home)/_sections/sdks.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FeatureCardContainer } from '@/components/feature-card';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { frameworks } from '@openpanel/sdk-info';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Sdks() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title="Get started in minutes"
|
||||
description="Integrate OpenPanel with your favorite framework using our lightweight SDKs. A few lines of code and you're tracking."
|
||||
className="mb-16"
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||
{frameworks.map((sdk) => (
|
||||
<Link href={sdk.href} key={sdk.key}>
|
||||
<FeatureCardContainer key={sdk.key}>
|
||||
<sdk.IconComponent className="size-6" />
|
||||
<div className="row justify-between items-center">
|
||||
<span className="text-sm font-semibold">{sdk.name}</span>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</div>
|
||||
</FeatureCardContainer>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
193
apps/public/src/app/(home)/_sections/testimonials.tsx
Normal file
193
apps/public/src/app/(home)/_sections/testimonials.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { InfiniteMovingCards } from '@/components/infinite-moving-cards';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { TwitterCard } from '@/components/twitter-card';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-steven.jpg',
|
||||
name: 'Steven Tey',
|
||||
handle: 'steventey',
|
||||
content: [
|
||||
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
|
||||
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
|
||||
'Built by @CarlLindesvard and it’s already tracking 750K+ events 🤩',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-pontus.jpg',
|
||||
name: 'Pontus Abrahamsson - oss/acc',
|
||||
handle: 'pontusab',
|
||||
content: ['Thanks, OpenPanel is a beast, love it!'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-piotr.jpg',
|
||||
name: 'Piotr Kulpinski',
|
||||
handle: 'piotrkulpinski',
|
||||
content: [
|
||||
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
|
||||
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-greg.png',
|
||||
name: 'greg hodson 🍜',
|
||||
handle: 'h0dson',
|
||||
content: ['i second this, openpanel is killing it'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-jacob.jpg',
|
||||
name: 'Jacob 🍀 Build in Public',
|
||||
handle: 'javayhuwx',
|
||||
content: [
|
||||
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
|
||||
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
|
||||
'#buildinpublic #indiehackers',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-lee.jpg',
|
||||
name: 'Lee',
|
||||
handle: 'DutchEngIishman',
|
||||
content: [
|
||||
'Day two of marketing.',
|
||||
'I like this upward trend..',
|
||||
'P.S. website went live on Sunday',
|
||||
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const isPausedRef = useRef(false);
|
||||
|
||||
// Duplicate items to create the illusion of infinite scrolling
|
||||
const duplicatedTestimonials = useMemo(
|
||||
() => [...testimonials, ...testimonials],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElement = scrollerRef.current;
|
||||
if (!scrollerElement) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
// When we've scrolled to the end of the first set, reset to the beginning
|
||||
// This creates a seamless infinite scroll effect
|
||||
const scrollWidth = scrollerElement.scrollWidth;
|
||||
const clientWidth = scrollerElement.clientWidth;
|
||||
const scrollLeft = scrollerElement.scrollLeft;
|
||||
|
||||
// Reset scroll position when we reach halfway (end of first set)
|
||||
if (scrollLeft + clientWidth >= scrollWidth / 2) {
|
||||
scrollerElement.scrollLeft = scrollLeft - scrollWidth / 2;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll functionality
|
||||
const autoScroll = () => {
|
||||
if (!isPausedRef.current && scrollerElement) {
|
||||
scrollerElement.scrollLeft += 0.5; // Adjust speed here
|
||||
animationFrameRef.current = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
};
|
||||
|
||||
scrollerElement.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Start auto-scrolling
|
||||
animationFrameRef.current = requestAnimationFrame(autoScroll);
|
||||
|
||||
// Pause on hover
|
||||
const handleMouseEnter = () => {
|
||||
isPausedRef.current = true;
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
isPausedRef.current = false;
|
||||
animationFrameRef.current = requestAnimationFrame(autoScroll);
|
||||
};
|
||||
|
||||
scrollerElement.addEventListener('mouseenter', handleMouseEnter);
|
||||
scrollerElement.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
scrollerElement.removeEventListener('scroll', handleScroll);
|
||||
scrollerElement.removeEventListener('mouseenter', handleMouseEnter);
|
||||
scrollerElement.removeEventListener('mouseleave', handleMouseLeave);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container mb-16">
|
||||
<SectionHeader
|
||||
title="Loved by builders everywhere"
|
||||
description="From indie hackers to global teams, OpenPanel helps people understand their users effortlessly."
|
||||
/>
|
||||
</div>
|
||||
<div className="relative -mx-4 px-4">
|
||||
{/* Gradient masks for fade effect */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-32 z-10 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right, hsl(var(--background)), transparent)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-32 z-10 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to left, hsl(var(--background)), transparent)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<InfiniteMovingCards
|
||||
items={testimonials}
|
||||
direction="left"
|
||||
pauseOnHover
|
||||
speed="slow"
|
||||
className="gap-8"
|
||||
renderItem={(item) => (
|
||||
<TwitterCard
|
||||
name={item.name}
|
||||
handle={item.handle}
|
||||
content={item.content}
|
||||
avatarUrl={item.avatarUrl}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
82
apps/public/src/app/(home)/_sections/why-openpanel.tsx
Normal file
82
apps/public/src/app/(home)/_sections/why-openpanel.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { FeatureCardBackground } from '@/components/feature-card';
|
||||
import { Section, SectionHeader, SectionLabel } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: 'Helpy UI',
|
||||
url: 'https://helpy-ui.com',
|
||||
logo: '/logos/helpy-ui.png',
|
||||
className: 'size-12',
|
||||
},
|
||||
{
|
||||
name: 'KiddoKitchen',
|
||||
url: 'https://kiddokitchen.se',
|
||||
logo: '/logos/kiddokitchen.png',
|
||||
},
|
||||
{
|
||||
name: 'Maneken',
|
||||
url: 'https://maneken.app',
|
||||
logo: '/logos/maneken.png',
|
||||
},
|
||||
{
|
||||
name: 'Midday',
|
||||
url: 'https://midday.ai',
|
||||
logo: '/logos/midday.png',
|
||||
},
|
||||
{
|
||||
name: 'Screenzen',
|
||||
url: 'https://www.screenzen.co',
|
||||
logo: '/logos/screenzen.png',
|
||||
},
|
||||
{
|
||||
name: 'Tiptip',
|
||||
url: 'https://tiptip.id',
|
||||
logo: '/logos/tiptip.png',
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyOpenPanel() {
|
||||
return (
|
||||
<Section className="container gap-16">
|
||||
<SectionHeader
|
||||
label="Trusted by builders"
|
||||
title="Join thousands of companies using OpenPanel to understand their users"
|
||||
/>
|
||||
<div className="col overflow-hidden">
|
||||
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4">
|
||||
USED BY
|
||||
</SectionLabel>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4">
|
||||
{images.map((image) => (
|
||||
<div key={image.logo} className="px-4 border-r last:border-r-0 ">
|
||||
<a
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
key={image.logo}
|
||||
className={cn('relative group center-center aspect-square')}
|
||||
title={image.name}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
<Image
|
||||
src={image.logo}
|
||||
alt={image.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className={cn(
|
||||
'size-16 object-contain dark:invert',
|
||||
image.className,
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
17
apps/public/src/app/(home)/layout.tsx
Normal file
17
apps/public/src/app/(home)/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 className="overflow-hidden">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
apps/public/src/app/(home)/page.tsx
Normal file
27
apps/public/src/app/(home)/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AnalyticsInsights } from './_sections/analytics-insights';
|
||||
import { Collaboration } from './_sections/collaboration';
|
||||
import { CtaBanner } from './_sections/cta-banner';
|
||||
import { DataPrivacy } from './_sections/data-privacy';
|
||||
import { Faq } from './_sections/faq';
|
||||
import { Hero } from './_sections/hero';
|
||||
import { Pricing } from './_sections/pricing';
|
||||
import { Sdks } from './_sections/sdks';
|
||||
import { Testimonials } from './_sections/testimonials';
|
||||
import { WhyOpenPanel } from './_sections/why-openpanel';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<AnalyticsInsights />
|
||||
<Collaboration />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<DataPrivacy />
|
||||
<Sdks />
|
||||
<Faq />
|
||||
<CtaBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
apps/public/src/app/api/[...op]/route.ts
Normal file
3
apps/public/src/app/api/[...op]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createRouteHandler } from '@openpanel/nextjs/server';
|
||||
|
||||
export const { GET, POST } = createRouteHandler();
|
||||
29
apps/public/src/app/api/headers/route.ts
Normal file
29
apps/public/src/app/api/headers/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const IP_HEADER_ORDER = [
|
||||
'cf-connecting-ip',
|
||||
'true-client-ip',
|
||||
'x-vercel-forwarded-for', // Vercel-specific, most reliable on Vercel
|
||||
'x-forwarded-for', // Standard proxy header (first IP in chain)
|
||||
'x-real-ip', // Alternative header
|
||||
'x-client-ip',
|
||||
'fastly-client-ip',
|
||||
'do-connecting-ip',
|
||||
'x-cluster-client-ip',
|
||||
];
|
||||
|
||||
export const GET = function POST(req: Request) {
|
||||
return NextResponse.json({
|
||||
headers: Object.fromEntries(req.headers),
|
||||
ips: IP_HEADER_ORDER.reduce(
|
||||
(acc, header) => {
|
||||
const value = req.headers.get(header);
|
||||
if (value) {
|
||||
acc[header] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
});
|
||||
};
|
||||
7
apps/public/src/app/api/search/route.ts
Normal file
7
apps/public/src/app/api/search/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source, {
|
||||
// https://docs.orama.com/docs/orama-js/supported-languages
|
||||
language: 'english',
|
||||
});
|
||||
55
apps/public/src/app/docs/[[...slug]]/page.tsx
Normal file
55
apps/public/src/app/docs/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { url } from '@/lib/layout.shared';
|
||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||
import { getPageImage, source } from '@/lib/source';
|
||||
import { getMDXComponents } from '@/mdx-components';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from 'fumadocs-ui/page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
// this allows you to link to other pages with relative file paths
|
||||
a: createRelativeLink(source, page),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<'/docs/[[...slug]]'>,
|
||||
): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return getPageMetadata({
|
||||
title: page.data.title,
|
||||
url: url(page.url),
|
||||
description: page.data.description ?? '',
|
||||
image: getOgImageUrl(page.url),
|
||||
});
|
||||
}
|
||||
11
apps/public/src/app/docs/layout.tsx
Normal file
11
apps/public/src/app/docs/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions()}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
207
apps/public/src/app/global.css
Normal file
207
apps/public/src/app/global.css
Normal file
@@ -0,0 +1,207 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant light (&:is(.light *));
|
||||
@custom-variant system (&:is(.system *));
|
||||
|
||||
body {
|
||||
@apply m-0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
--spacing-fd-container: 1000px;
|
||||
}
|
||||
|
||||
@utility container {
|
||||
@media (width >= 40rem) {
|
||||
@apply max-w-[1000px];
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.269 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Light mode colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Fonts */
|
||||
--font-family-mono: var(--font-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* Scroll animations */
|
||||
--animate-scroll: scroll var(--animation-duration, 40s) var(--animation-direction, forwards) linear infinite;
|
||||
|
||||
@keyframes scroll {
|
||||
to {
|
||||
transform: translate(calc(-50% - 0.5rem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility pulled {
|
||||
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
|
||||
}
|
||||
|
||||
@utility center-center {
|
||||
@apply flex items-center justify-center text-center;
|
||||
}
|
||||
|
||||
@utility col {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
@utility row {
|
||||
@apply flex flex-row;
|
||||
}
|
||||
|
||||
@utility text-text {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-testimonials {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.line-before {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-before:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
.line-after {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-after:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
right: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
44
apps/public/src/app/layout.tsx
Normal file
44
apps/public/src/app/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { getRootMetadata } from '@/lib/metadata';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './global.css';
|
||||
|
||||
const font = Geist({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
const mono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = getRootMetadata();
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(font.className, mono.variable)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="flex flex-col min-h-screen bg-background">
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
10
apps/public/src/app/llms-full.txt/route.ts
Normal file
10
apps/public/src/app/llms-full.txt/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getLLMText, source } from '@/lib/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join('\n\n'));
|
||||
}
|
||||
233
apps/public/src/app/og/[...og]/route.tsx
Normal file
233
apps/public/src/app/og/[...og]/route.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { getAllCompareSlugs, getCompareData } from '@/lib/compare';
|
||||
import { url as baseUrl } from '@/lib/layout.shared';
|
||||
import { articleSource, pageSource, source } from '@/lib/source';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
// Truncate text helper
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.substring(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
async function getOgData(
|
||||
segments: string[],
|
||||
): Promise<{ title: string; description?: string }> {
|
||||
switch (segments[0]) {
|
||||
case 'default':
|
||||
return {
|
||||
title: 'Home',
|
||||
description: 'Home page',
|
||||
};
|
||||
case 'supporter': {
|
||||
return {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
};
|
||||
}
|
||||
case 'pricing': {
|
||||
return {
|
||||
title: 'Pricing',
|
||||
description:
|
||||
'Our pricing is as simple as it gets, choose how many events you want to track each month, everything else is unlimited, no tiers, no hidden costs.',
|
||||
};
|
||||
}
|
||||
case 'articles': {
|
||||
if (segments.length > 1) {
|
||||
const data = await articleSource.getPage(segments.slice(1));
|
||||
return {
|
||||
title: data?.data.title ?? 'Article Not Found',
|
||||
description:
|
||||
data?.data.description || 'Whooops, could not find this article',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Articles',
|
||||
description:
|
||||
'Read our latest articles and stay up to date with the latest news and updates.',
|
||||
};
|
||||
}
|
||||
case 'compare': {
|
||||
const slug = segments[1];
|
||||
if (!slug) {
|
||||
return {
|
||||
title: 'Compare alternatives',
|
||||
description: 'Compare OpenPanel with other analytics tools',
|
||||
};
|
||||
}
|
||||
const data = await getCompareData(slug);
|
||||
return {
|
||||
title: data?.seo.title || data?.hero.heading || 'Compare',
|
||||
description: data?.seo.description || data?.hero.subheading,
|
||||
};
|
||||
}
|
||||
case 'docs': {
|
||||
const data = await source.getPage(segments.slice(1));
|
||||
return {
|
||||
title: data?.data.title ?? 'Page Not Found',
|
||||
description:
|
||||
data?.data.description || 'Whooops, could not find this page',
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const data = await pageSource.getPage(segments);
|
||||
return {
|
||||
title: data?.data.title || 'Page Not Found',
|
||||
description:
|
||||
data?.data.description || 'Whooops, could not find this page',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// export async function generateStaticParams() {
|
||||
// const params: { og: string[] }[] = [];
|
||||
|
||||
// // Static pages
|
||||
// params.push({ og: ['default'] });
|
||||
// params.push({ og: ['supporter'] });
|
||||
// params.push({ og: ['pricing'] });
|
||||
// params.push({ og: ['articles'] });
|
||||
// params.push({ og: ['compare'] });
|
||||
// params.push({ og: ['docs'] });
|
||||
|
||||
// // Articles
|
||||
// const articles = await articleSource.getPages();
|
||||
// for (const article of articles) {
|
||||
// const slug = article.url.replace(/^\/articles\//, '').replace(/\/$/, '');
|
||||
// params.push({ og: ['articles', slug] });
|
||||
// }
|
||||
|
||||
// // Compare pages
|
||||
// const compareSlugs = await getAllCompareSlugs();
|
||||
// for (const slug of compareSlugs) {
|
||||
// params.push({ og: ['compare', slug] });
|
||||
// }
|
||||
|
||||
// // Docs pages
|
||||
// const docs = await source.getPages();
|
||||
// for (const doc of docs) {
|
||||
// params.push({ og: ['docs', ...doc.slugs] });
|
||||
// }
|
||||
|
||||
// // Other pages
|
||||
// const pages = await pageSource.getPages();
|
||||
// for (const page of pages) {
|
||||
// params.push({ og: page.slugs });
|
||||
// }
|
||||
|
||||
// return params;
|
||||
// }
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ og: string[] }> },
|
||||
) {
|
||||
try {
|
||||
const { og } = await params;
|
||||
|
||||
// Get OG data based on segments
|
||||
const { title, description } = await getOgData(og);
|
||||
|
||||
// Truncate title and description
|
||||
const truncatedTitle = truncateText(title, 100);
|
||||
const truncatedDescription = description
|
||||
? truncateText(description, 200)
|
||||
: undefined;
|
||||
|
||||
// Get background image URL
|
||||
const backgroundImageUrl = baseUrl('/ogimage-empty.png');
|
||||
|
||||
// Fetch Geist font files from CDN (cache fonts for better performance)
|
||||
const [geistRegular, geistBold] = await Promise.all([
|
||||
fetch(
|
||||
'https://cdn.jsdelivr.net/npm/geist@1.5.1/dist/fonts/geist-sans/Geist-Regular.ttf',
|
||||
).then((res) => res.arrayBuffer()),
|
||||
fetch(
|
||||
'https://cdn.jsdelivr.net/npm/geist@1.5.1/dist/fonts/geist-sans/Geist-Bold.ttf',
|
||||
).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
backgroundImage: `url(${backgroundImageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{/* Title and Description at bottom left */}
|
||||
<div
|
||||
style={{
|
||||
bottom: '55px',
|
||||
left: '55px',
|
||||
maxWidth: '900px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: truncatedTitle.length > 40 ? '56px' : '72px',
|
||||
fontFamily: 'GeistBold',
|
||||
color: '#000',
|
||||
lineHeight: 1.1,
|
||||
marginBottom: truncatedDescription ? '20px' : '0',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{truncatedTitle}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{truncatedDescription ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '30px',
|
||||
fontFamily: 'Geist',
|
||||
color: '#666',
|
||||
lineHeight: 1.4,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{truncatedDescription}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Geist',
|
||||
data: geistRegular,
|
||||
style: 'normal',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: 'GeistBold',
|
||||
data: geistBold,
|
||||
style: 'normal',
|
||||
weight: 700,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to generate OG image: ${e.message}`);
|
||||
return new Response('Failed to generate the image', {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
4
apps/public/src/app/robots.txt
Normal file
4
apps/public/src/app/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
Allow: /og*
|
||||
Sitemap: https://openpanel.dev/sitemap.xml
|
||||
68
apps/public/src/app/sitemap.ts
Normal file
68
apps/public/src/app/sitemap.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { url } from '@/lib/layout.shared';
|
||||
import { articleSource, compareSource, pageSource, source } from '@/lib/source';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const articles = await articleSource.getPages();
|
||||
const docs = await source.getPages();
|
||||
const pages = await pageSource.getPages();
|
||||
return [
|
||||
{
|
||||
url: url('/'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: url('/docs'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: url('/pricing'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: url('/articles'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url('/compare'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url('/supporter'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
...articles.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.date,
|
||||
changeFrequency: 'yearly' as const,
|
||||
priority: 0.5,
|
||||
})),
|
||||
...docs.map((item) => ({
|
||||
url: url(item.url),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
...pages.map((item) => ({
|
||||
url: url(item.url),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
...compareSource.map((item) => ({
|
||||
url: url(item.url),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
];
|
||||
}
|
||||
43
apps/public/src/components/article-card.tsx
Normal file
43
apps/public/src/components/article-card.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ArticleCard({
|
||||
url,
|
||||
title,
|
||||
tag,
|
||||
cover,
|
||||
team,
|
||||
date,
|
||||
}: {
|
||||
url: string;
|
||||
title: string;
|
||||
tag?: string;
|
||||
cover: string;
|
||||
team?: string;
|
||||
date: Date;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
key={url}
|
||||
className="border rounded-lg overflow-hidden bg-background-light col hover:scale-105 transition-all duration-300 hover:shadow-lg hover:shadow-background-dark"
|
||||
>
|
||||
<Image
|
||||
src={cover}
|
||||
alt={title}
|
||||
width={323}
|
||||
height={181}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="p-4 col flex-1">
|
||||
{tag && <span className="font-mono text-xs mb-2">{tag}</span>}
|
||||
<span className="flex-1 mb-6">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
28
apps/public/src/components/battery-icon.tsx
Normal file
28
apps/public/src/components/battery-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import {
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
type LucideProps,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function BatteryIcon(props: LucideProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const icons = [BatteryLowIcon, BatteryMediumIcon, BatteryFullIcon];
|
||||
|
||||
const Icon = icons[index];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((index + 1) % icons.length);
|
||||
}, 750);
|
||||
return () => clearInterval(interval);
|
||||
}, [index]);
|
||||
|
||||
if (!Icon) {
|
||||
return <div className={props.className} />;
|
||||
}
|
||||
|
||||
return <Icon {...props} />;
|
||||
}
|
||||
7
apps/public/src/components/common-sdk-config.mdx
Normal file
7
apps/public/src/components/common-sdk-config.mdx
Normal file
@@ -0,0 +1,7 @@
|
||||
##### Common options
|
||||
|
||||
- `apiUrl` - The url of the openpanel API or your self-hosted instance
|
||||
- `clientId` - The client id of your application
|
||||
- `clientSecret` - The client secret of your application (**only required for server-side events**)
|
||||
- `filter` - A function that will be called before sending an event. If it returns false, the event will not be sent
|
||||
- `disabled` - If true, the library will not send any events
|
||||
80
apps/public/src/components/competition.tsx
Normal file
80
apps/public/src/components/competition.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const brandConfig = {
|
||||
Mixpanel: '#7A59FF',
|
||||
'Google Analytics': '#E37400',
|
||||
Amplitude: '#00CF98',
|
||||
} as const;
|
||||
|
||||
const words = Object.keys(brandConfig);
|
||||
|
||||
function useWordCycle(words: string[], interval: number, mounted: boolean) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [isInitial, setIsInitial] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
setIndex(Math.floor(Math.random() * words.length));
|
||||
setIsInitial(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setIndex((current) => (current + 1) % words.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [words, interval, isInitial, mounted]);
|
||||
|
||||
return words[index];
|
||||
}
|
||||
|
||||
export function Competition() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const word = useWordCycle(words, 2100, mounted);
|
||||
const color = brandConfig[word as keyof typeof brandConfig];
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<span className="block truncate leading-tight -mt-1" style={{ color }}>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={word}
|
||||
className="block truncate leading-tight -mt-1"
|
||||
style={{ color }}
|
||||
>
|
||||
{word?.split('').map((char, index) => (
|
||||
<motion.span
|
||||
key={`${word}-${char}-${index.toString()}`}
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -10, opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
delay: index * 0.015,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
style={{ display: 'inline-block', whiteSpace: 'pre' }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
12
apps/public/src/components/device-id-warning.tsx
Normal file
12
apps/public/src/components/device-id-warning.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function DeviceIdWarning() {
|
||||
return (
|
||||
<Callout>
|
||||
Read more about{' '}
|
||||
<Link href="/docs/device-id">device id and why you might want it</Link>.
|
||||
**We recommend not to but it's up to you.**
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
47
apps/public/src/components/faq.tsx
Normal file
47
apps/public/src/components/faq.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Script from 'next/script';
|
||||
import Markdown from 'react-markdown';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from './ui/accordion';
|
||||
|
||||
export const Faqs = ({ children }: { children: React.ReactNode }) => (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center border rounded-3xl [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
|
||||
>
|
||||
{children}
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
export const FaqItem = ({
|
||||
question,
|
||||
children,
|
||||
}: { question: string; children: string | React.ReactNode }) => (
|
||||
<AccordionItem
|
||||
value={question}
|
||||
itemScope
|
||||
itemProp="mainEntity"
|
||||
itemType="https://schema.org/Question"
|
||||
className="[&_[role=region]]:px-4"
|
||||
>
|
||||
<AccordionTrigger className="text-left" itemProp="name">
|
||||
{question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
itemProp="acceptedAnswer"
|
||||
itemScope
|
||||
itemType="https://schema.org/Answer"
|
||||
className="prose"
|
||||
>
|
||||
{typeof children === 'string' ? (
|
||||
<Markdown>{children}</Markdown>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
76
apps/public/src/components/feature-card.tsx
Normal file
76
apps/public/src/components/feature-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FeatureCardProps {
|
||||
illustration?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'large';
|
||||
}
|
||||
|
||||
interface FeatureCardContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FeatureCardBackground = () => (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 bg-linear-to-br opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100',
|
||||
'dark:from-blue-500/10 dark:via-transparent dark:to-emerald-500/5',
|
||||
'light:from-blue-800/20 light:via-transparent light:to-emerald-900/10',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export function FeatureCardContainer({
|
||||
children,
|
||||
className,
|
||||
}: FeatureCardContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col gap-8 p-6 rounded-3xl border bg-background group relative overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureCard({
|
||||
illustration,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
}: FeatureCardProps) {
|
||||
if (illustration) {
|
||||
return (
|
||||
<FeatureCardContainer className={className}>
|
||||
{illustration}
|
||||
<div className="col gap-2" data-content>
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureCardContainer className={className}>
|
||||
{Icon && <Icon className="size-6" />}
|
||||
<div className="col gap-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
24
apps/public/src/components/figure.tsx
Normal file
24
apps/public/src/components/figure.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function Figure({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
className,
|
||||
}: { src: string; alt: string; caption: string; className?: string }) {
|
||||
return (
|
||||
<figure className={cn('-mx-4', className)}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt || caption}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<figcaption className="text-center text-sm text-muted-foreground mt-2">
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
79
apps/public/src/components/flow-step.tsx
Normal file
79
apps/public/src/components/flow-step.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FlowStepProps {
|
||||
step: number;
|
||||
actor: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
visitor: User,
|
||||
website: Globe,
|
||||
backend: Server,
|
||||
payment: CreditCard,
|
||||
success: CheckCircle,
|
||||
};
|
||||
|
||||
const iconColorMap = {
|
||||
visitor: 'text-blue-500',
|
||||
website: 'text-green-500',
|
||||
backend: 'text-purple-500',
|
||||
payment: 'text-yellow-500',
|
||||
success: 'text-green-600',
|
||||
};
|
||||
|
||||
const iconBorderColorMap = {
|
||||
visitor: 'border-blue-500',
|
||||
website: 'border-green-500',
|
||||
backend: 'border-purple-500',
|
||||
payment: 'border-yellow-500',
|
||||
success: 'border-green-600',
|
||||
};
|
||||
|
||||
export function FlowStep({
|
||||
step,
|
||||
actor,
|
||||
description,
|
||||
children,
|
||||
icon = 'visitor',
|
||||
isLast = false,
|
||||
}: FlowStepProps) {
|
||||
const Icon = iconMap[icon];
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 mb-4 min-w-0">
|
||||
{/* Step number and icon */}
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="relative z-10 bg-background">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
|
||||
{step}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
|
||||
>
|
||||
<Icon
|
||||
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector line - extends from badge through content to next step */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pt-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
{children && <div className="mt-3 min-w-0">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
apps/public/src/components/footer.tsx
Normal file
175
apps/public/src/components/footer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { articleSource, compareSource } from '@/lib/source';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Logo } from './logo';
|
||||
export async function Footer() {
|
||||
const articles = (await articleSource.getPages()).sort(
|
||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
||||
);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<footer className="text-sm relative overflow-hidden pt-32">
|
||||
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
|
||||
<div className="absolute inset-0 bg-linear-to-b from-background to-transparent" />
|
||||
<Logo className="w-[900px] shrink-0" />
|
||||
</div>
|
||||
<div className="container grid grid-cols-1 md:grid-cols-4 gap-12 md:gap-8 relative">
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Useful links</h3>
|
||||
<Links
|
||||
data={[
|
||||
{ title: 'About', url: '/about' },
|
||||
{ title: 'Contact', url: '/contact' },
|
||||
{ title: 'Become a supporter', url: '/supporter' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Resources</h3>
|
||||
<Links
|
||||
data={[
|
||||
{ title: 'Pricing', url: '/pricing' },
|
||||
{ title: 'Documentation', url: '/docs' },
|
||||
{ title: 'SDKs', url: '/docs/sdks' },
|
||||
{ title: 'Articles', url: '/articles' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Compare</h3>
|
||||
<Links
|
||||
data={compareSource.map((item) => ({
|
||||
url: item.url,
|
||||
title: item?.hero?.heading,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Latest articles</h3>
|
||||
<Links
|
||||
data={articles.slice(0, 10).map((article) => ({
|
||||
title: article.data.title,
|
||||
url: article.url,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||
<div className="container col md:row justify-between gap-8">
|
||||
<div>
|
||||
<Link href="/" className="row items-center font-medium -ml-3">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions().nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
<Social />
|
||||
</div>
|
||||
<div className="container flex flex-col-reverse md:row justify-between gap-8">
|
||||
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
|
||||
<div className="col lg:row gap-2 md:gap-4">
|
||||
<Link href="/sitemap.xml">Sitemap</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/terms">Terms of Service</Link>
|
||||
<Link href="/cookies">Cookie Policy (just kidding)</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Links({ data }: { data: { title: string; url: string }[] }) {
|
||||
return (
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
{data.map((item) => (
|
||||
<li key={item.url} className="truncate">
|
||||
<Link
|
||||
href={item.url}
|
||||
className="hover:text-foreground transition-colors"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Social() {
|
||||
return (
|
||||
<div className="md:items-end col gap-4">
|
||||
<div className="[&_svg]:size-6 row gap-4">
|
||||
<Link
|
||||
title="Go to GitHub"
|
||||
href="https://github.com/Openpanel-dev/openpanel"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Go to X"
|
||||
href="https://x.com/openpaneldev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>X</title>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Join Discord"
|
||||
href="https://go.openpanel.dev/discord"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Send an email"
|
||||
href="mailto:hello@openpanel.dev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<MailIcon className="size-6" />
|
||||
</Link>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://status.openpanel.dev"
|
||||
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start max-md:ml-auto"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<span>Operational</span>
|
||||
<div className="size-2 bg-emerald-500 rounded-full" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
apps/public/src/components/get-started-button.tsx
Normal file
23
apps/public/src/components/get-started-button.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function GetStartedButton({
|
||||
text,
|
||||
href = 'https://dashboard.openpanel.dev/onboarding',
|
||||
className,
|
||||
}: {
|
||||
text?: React.ReactNode;
|
||||
className?: string;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button size="lg" asChild className={cn('group', className)}>
|
||||
<Link href={href}>
|
||||
{text ?? 'Get started now'}
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
42
apps/public/src/components/github-button.tsx
Normal file
42
apps/public/src/components/github-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getGithubRepoInfo } from '@/lib/github';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
function formatStars(stars: number) {
|
||||
if (stars >= 1000) {
|
||||
const k = stars / 1000;
|
||||
return `${k.toFixed(k >= 10 ? 0 : 1)}k`;
|
||||
}
|
||||
return stars.toString();
|
||||
}
|
||||
|
||||
export function GithubButton() {
|
||||
const [stars, setStars] = useState(4_800);
|
||||
useEffect(() => {
|
||||
getGithubRepoInfo().then((res) => {
|
||||
if (res?.stargazers_count) {
|
||||
setStars(res.stargazers_count);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<Button variant={'secondary'} asChild>
|
||||
<Link href="https://git.new/openpanel" className="hidden md:flex">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{formatStars(stars)} stars
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
99
apps/public/src/components/infinite-moving-cards.tsx
Normal file
99
apps/public/src/components/infinite-moving-cards.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// Thank you: https://ui.aceternity.com/components/infinite-moving-cards
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export const InfiniteMovingCards = <T,>({
|
||||
items,
|
||||
direction = 'left',
|
||||
speed = 'fast',
|
||||
pauseOnHover = true,
|
||||
className,
|
||||
renderItem,
|
||||
}: {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
direction?: 'left' | 'right';
|
||||
speed?: 'fast' | 'normal' | 'slow';
|
||||
pauseOnHover?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollerRef = React.useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
addAnimation();
|
||||
}, []);
|
||||
|
||||
const [start, setStart] = useState(false);
|
||||
function addAnimation() {
|
||||
if (containerRef.current && scrollerRef.current) {
|
||||
const scrollerContent = Array.from(scrollerRef.current.children);
|
||||
|
||||
scrollerContent.forEach((item) => {
|
||||
const duplicatedItem = item.cloneNode(true);
|
||||
if (scrollerRef.current) {
|
||||
scrollerRef.current.appendChild(duplicatedItem);
|
||||
}
|
||||
});
|
||||
|
||||
getDirection();
|
||||
getSpeed();
|
||||
setStart(true);
|
||||
}
|
||||
}
|
||||
const getDirection = () => {
|
||||
if (containerRef.current) {
|
||||
if (direction === 'left') {
|
||||
containerRef.current.style.setProperty(
|
||||
'--animation-direction',
|
||||
'forwards',
|
||||
);
|
||||
} else {
|
||||
containerRef.current.style.setProperty(
|
||||
'--animation-direction',
|
||||
'reverse',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getSpeed = () => {
|
||||
if (containerRef.current) {
|
||||
if (speed === 'fast') {
|
||||
containerRef.current.style.setProperty('--animation-duration', '20s');
|
||||
} else if (speed === 'normal') {
|
||||
containerRef.current.style.setProperty('--animation-duration', '40s');
|
||||
} else {
|
||||
containerRef.current.style.setProperty('--animation-duration', '80s');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'scroller relative z-20 overflow-hidden -ml-4 md:-ml-[1200px] w-screen md:w-[calc(100vw+1400px)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ul
|
||||
ref={scrollerRef}
|
||||
className={cn(
|
||||
'flex min-w-full shrink-0 gap-8 py-4 w-max flex-nowrap items-start',
|
||||
start && 'animate-scroll',
|
||||
pauseOnHover && 'hover:[animation-play-state:paused]',
|
||||
)}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<li
|
||||
className="w-[310px] max-w-full relative shrink-0 md:w-[400px]"
|
||||
key={idx.toString()}
|
||||
>
|
||||
{renderItem(item, idx)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
apps/public/src/components/logo.tsx
Normal file
32
apps/public/src/components/logo.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 61 35"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('text-black dark:text-white w-16', className)}
|
||||
>
|
||||
<rect
|
||||
x="34.0269"
|
||||
y="0.368164"
|
||||
width="10.3474"
|
||||
height="34.2258"
|
||||
rx="5.17372"
|
||||
/>
|
||||
<rect
|
||||
x="49.9458"
|
||||
y="0.368164"
|
||||
width="10.3474"
|
||||
height="17.5109"
|
||||
rx="5.17372"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14.212 0C6.36293 0 0 6.36293 0 14.212V20.02C0 27.8691 6.36293 34.232 14.212 34.232C22.0611 34.232 28.424 27.8691 28.424 20.02V14.212C28.424 6.36293 22.0611 0 14.212 0ZM14.2379 8.35999C11.3805 8.35999 9.06419 10.6763 9.06419 13.5337V20.6971C9.06419 23.5545 11.3805 25.8708 14.2379 25.8708C17.0953 25.8708 19.4116 23.5545 19.4116 20.6971V13.5337C19.4116 10.6763 17.0953 8.35999 14.2379 8.35999Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
216
apps/public/src/components/navbar.tsx
Normal file
216
apps/public/src/components/navbar.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { MenuIcon, MoonIcon, SunIcon, XIcon } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FeatureCardContainer } from './feature-card';
|
||||
import { GithubButton } from './github-button';
|
||||
import { Logo } from './logo';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
text: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
text: 'Pricing',
|
||||
url: '/pricing',
|
||||
},
|
||||
{
|
||||
text: 'Supporter',
|
||||
url: '/supporter',
|
||||
},
|
||||
{
|
||||
text: 'Docs',
|
||||
url: '/docs',
|
||||
},
|
||||
{
|
||||
text: 'Articles',
|
||||
url: '/articles',
|
||||
},
|
||||
];
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const navbarRef = useRef<HTMLDivElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If click outside of the menu, close it
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleClick);
|
||||
return () => window.removeEventListener('click', handleClick);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed top-0 z-50 w-full py-4 border-b transition-colors duration-500',
|
||||
isScrolled
|
||||
? 'bg-background border-border'
|
||||
: 'bg-background/0 border-border/0',
|
||||
)}
|
||||
ref={navbarRef}
|
||||
>
|
||||
<div className="container">
|
||||
<div className={cn('flex justify-between items-center')}>
|
||||
{/* Logo */}
|
||||
<div className="shrink-0">
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
<span className="hidden [@media(min-width:850px)]:block">
|
||||
{baseOptions().nav?.title}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row items-center gap-8">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8 text-sm">
|
||||
{LINKS?.map((link) => {
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground font-medium"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubButton />
|
||||
{/* Sign in button */}
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="hidden md:flex"
|
||||
href="https://dashboard.openpanel.dev/onboarding"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
className="md:hidden -my-2"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen((p) => !p);
|
||||
}}
|
||||
>
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
ref={mobileMenuRef}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden bg-background/20 md:hidden backdrop-blur-lg fixed inset-0 p-4"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target === mobileMenuRef.current) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FeatureCardContainer className="col text-sm divide-y divide-foreground/10 gap-0 mt-12 py-4">
|
||||
{LINKS?.map((link) => {
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground text-lg font-semibold p-4 px-0"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</FeatureCardContainer>
|
||||
<div className="row gap-2 absolute top-3 right-4 items-center">
|
||||
<ThemeToggle className="flex!" />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
function ThemeToggle({ className }: { className?: string }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="naked"
|
||||
onClick={() => theme.setTheme(theme.theme === 'dark' ? 'light' : 'dark')}
|
||||
className={cn(
|
||||
'relative overflow-hidden size-8 cursor-pointer hidden md:inline',
|
||||
className,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{theme.theme === 'dark' ||
|
||||
(!theme.theme && theme.resolvedTheme === 'dark') ? (
|
||||
<motion.div
|
||||
key="moon"
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: 90, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<MoonIcon className="size-4" suppressHydrationWarning />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="sun"
|
||||
initial={{ rotate: 90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: -90, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<SunIcon className="size-4" suppressHydrationWarning />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
18
apps/public/src/components/perks.tsx
Normal file
18
apps/public/src/components/perks.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export function Perks({
|
||||
perks,
|
||||
className,
|
||||
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
|
||||
return (
|
||||
<ul className={cn('grid grid-cols-2 gap-2', className)}>
|
||||
{perks.map((perk) => (
|
||||
<li key={perk.text} className="text-sm text-muted-foreground">
|
||||
<perk.icon className="size-4 inline-block mr-2 relative -top-px" />
|
||||
{perk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
10
apps/public/src/components/personal-data-warning.tsx
Normal file
10
apps/public/src/components/personal-data-warning.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
export function PersonalDataWarning() {
|
||||
return (
|
||||
<Callout>
|
||||
Keep in mind that this is considered personal data. Make sure you have the
|
||||
users consent before calling this!
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
64
apps/public/src/components/pricing-slider.tsx
Normal file
64
apps/public/src/components/pricing-slider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRICING } from '@openpanel/payments/prices';
|
||||
import { useState } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
|
||||
export function PricingSlider() {
|
||||
const [index, setIndex] = useState(2);
|
||||
const match = PRICING[index];
|
||||
const formatNumber = (value: number) => value.toLocaleString();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Slider
|
||||
value={[index]}
|
||||
max={PRICING.length}
|
||||
step={1}
|
||||
tooltip={
|
||||
match
|
||||
? `${formatNumber(match.events)} events per month`
|
||||
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
|
||||
}
|
||||
onValueChange={(value) => setIndex(value[0])}
|
||||
/>
|
||||
|
||||
{match ? (
|
||||
<div>
|
||||
<div>
|
||||
<NumberFlow
|
||||
className="text-5xl"
|
||||
value={match.price}
|
||||
format={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}}
|
||||
locales={'en-US'}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground ml-2">/ month</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground italic opacity-100',
|
||||
match.price === 0 && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
+ VAT if applicable
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-lg">
|
||||
Contact us at{' '}
|
||||
<a className="underline" href="mailto:hello@openpanel.dev">
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
to get a custom quote.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
apps/public/src/components/section.tsx
Normal file
81
apps/public/src/components/section.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={cn('my-32 col', className)} {...props}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: 'text-3xl md:text-5xl font-semibold',
|
||||
sm: 'text-3xl md:text-4xl font-semibold',
|
||||
};
|
||||
|
||||
export function SectionHeader({
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
align,
|
||||
as = 'h2',
|
||||
variant = 'default',
|
||||
}: {
|
||||
label?: string;
|
||||
title: string | React.ReactNode;
|
||||
description?: string;
|
||||
className?: string;
|
||||
align?: 'center' | 'left';
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
variant?: keyof typeof variants;
|
||||
}) {
|
||||
const Heading = as;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col gap-4',
|
||||
align === 'center'
|
||||
? 'center-center text-center'
|
||||
: 'items-start text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label && <SectionLabel>{label}</SectionLabel>}
|
||||
<Heading className={cn(variants[variant], 'max-w-3xl leading-tight')}>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<p className={cn('text-muted-foreground max-w-3xl')}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs uppercase tracking-wider text-muted-foreground font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
70
apps/public/src/components/simple-chart.tsx
Normal file
70
apps/public/src/components/simple-chart.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface SimpleChartProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
points?: number[];
|
||||
strokeWidth?: number;
|
||||
strokeColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SimpleChart({
|
||||
width = 300,
|
||||
height = 100,
|
||||
points = [0, 10, 5, 8, 12, 4, 7],
|
||||
strokeWidth = 2,
|
||||
strokeColor = '#2563eb',
|
||||
className,
|
||||
}: SimpleChartProps) {
|
||||
// Skip if no points
|
||||
if (!points.length) return null;
|
||||
|
||||
// Calculate scaling factors
|
||||
const maxValue = Math.max(...points);
|
||||
const xStep = width / (points.length - 1);
|
||||
const yScale = height / maxValue;
|
||||
|
||||
// Generate path commands
|
||||
const pathCommands = points
|
||||
.map((point, index) => {
|
||||
const x = index * xStep;
|
||||
const y = height - point * yScale;
|
||||
return `${index === 0 ? 'M' : 'L'} ${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
// Create area path by adding bottom corners
|
||||
const areaPath = `${pathCommands} L ${width},${height} L 0,${height} Z`;
|
||||
|
||||
// Generate unique gradient ID
|
||||
const gradientId = `gradient-${strokeColor
|
||||
.replace('#', '')
|
||||
.replaceAll('(', '')
|
||||
.replaceAll(')', '')}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={`w-full ${className ?? ''}`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Area fill */}
|
||||
{/* <path d={areaPath} fill={`url(#${gradientId})`} /> */}
|
||||
|
||||
{/* Stroke line */}
|
||||
<path
|
||||
d={pathCommands}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
apps/public/src/components/tag.tsx
Normal file
30
apps/public/src/components/tag.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
|
||||
const tagVariants = cva(
|
||||
'shadow-sm px-4 gap-2 center-center border self-auto text-xs rounded-full h-7',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
light:
|
||||
'bg-background-light dark:bg-background-dark text-muted-foreground',
|
||||
dark: 'bg-foreground-light dark:bg-foreground-dark text-muted border-background/10 shadow-background/5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'light',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface TagProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof tagVariants> {}
|
||||
|
||||
export function Tag({ children, className, variant, ...props }: TagProps) {
|
||||
return (
|
||||
<span className={cn(tagVariants({ variant, className }))} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
34
apps/public/src/components/toc.tsx
Normal file
34
apps/public/src/components/toc.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { TableOfContents } from 'fumadocs-core/toc';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FeatureCardContainer } from './feature-card';
|
||||
|
||||
interface Props {
|
||||
toc: TableOfContents;
|
||||
}
|
||||
|
||||
export const Toc: React.FC<Props> = ({ toc }) => {
|
||||
return (
|
||||
<FeatureCardContainer className="gap-2">
|
||||
<span className="text-lg font-semibold">Table of contents</span>
|
||||
<ul>
|
||||
{toc.map((item) => (
|
||||
<li
|
||||
key={item.url}
|
||||
className="py-1"
|
||||
style={{ marginLeft: `${(item.depth - 2) * (4 * 4)}px` }}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className="hover:underline row gap-2 items-center group/toc-item"
|
||||
title={item.title?.toString() ?? ''}
|
||||
>
|
||||
<ArrowRightIcon className="shrink-0 w-4 h-4 opacity-30 group-hover/toc-item:opacity-100 transition-opacity" />
|
||||
<span className="truncate text-sm">{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
};
|
||||
92
apps/public/src/components/twitter-card.tsx
Normal file
92
apps/public/src/components/twitter-card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
BadgeIcon,
|
||||
CheckCheckIcon,
|
||||
CheckIcon,
|
||||
HeartIcon,
|
||||
MessageCircleIcon,
|
||||
RefreshCwIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface TwitterCardProps {
|
||||
avatarUrl?: string;
|
||||
name: string;
|
||||
handle: string;
|
||||
content: React.ReactNode;
|
||||
replies?: number;
|
||||
retweets?: number;
|
||||
likes?: number;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export function TwitterCard({
|
||||
avatarUrl,
|
||||
name,
|
||||
handle,
|
||||
content,
|
||||
replies = 0,
|
||||
retweets = 0,
|
||||
likes = 0,
|
||||
verified = false,
|
||||
}: TwitterCardProps) {
|
||||
const renderContent = () => {
|
||||
if (typeof content === 'string') {
|
||||
return <p className="text-sm">{content}</p>;
|
||||
}
|
||||
|
||||
if (Array.isArray(content) && typeof content[0] === 'string') {
|
||||
return content.map((line) => (
|
||||
<p key={line} className="text-sm">
|
||||
{line}
|
||||
</p>
|
||||
));
|
||||
}
|
||||
|
||||
return <div className="text-sm">{content}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-3xl p-8 col gap-4 bg-background-light">
|
||||
<div className="row gap-4">
|
||||
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
|
||||
{avatarUrl && (
|
||||
<Image src={avatarUrl} alt={name} width={48} height={48} />
|
||||
)}
|
||||
</div>
|
||||
<div className="col gap-4">
|
||||
<div className="col gap-2">
|
||||
<div className="">
|
||||
<span className="font-medium">{name}</span>
|
||||
{verified && (
|
||||
<div className="relative inline-block top-0.5 ml-1">
|
||||
<BadgeIcon className="size-4 fill-[#1D9BF0] text-[#1D9BF0]" />
|
||||
<div className="absolute inset-0 center-center">
|
||||
<CheckIcon className="size-2 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm leading-0">
|
||||
@{handle}
|
||||
</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
<div className="row gap-4 text-muted-foreground text-sm">
|
||||
<div className="row gap-2">
|
||||
<MessageCircleIcon className="transition-all size-4 fill-background hover:fill-blue-500 hover:text-blue-500" />
|
||||
{/* <span>{replies}</span> */}
|
||||
</div>
|
||||
<div className="row gap-2">
|
||||
<RefreshCwIcon className="transition-all size-4 fill-background hover:text-blue-500" />
|
||||
{/* <span>{retweets}</span> */}
|
||||
</div>
|
||||
<div className="row gap-2">
|
||||
<HeartIcon className="transition-all size-4 fill-background hover:fill-rose-500 hover:text-rose-500" />
|
||||
{/* <span>{likes}</span> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/public/src/components/ui/accordion.tsx
Normal file
76
apps/public/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FeatureCardBackground } from '../feature-card';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item>>;
|
||||
}) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Trigger>>;
|
||||
}) => (
|
||||
<AccordionPrimitive.Header className="flex not-prose">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group relative overflow-hidden flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180 cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Content>>;
|
||||
}) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-muted-foreground"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pb-4 pt-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
65
apps/public/src/components/ui/button.tsx
Normal file
65
apps/public/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'hover:-translate-y-px inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-foreground text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
naked:
|
||||
'bg-transparent hover:bg-transparent ring-0 border-none !px-0 !py-0 shadow-none',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 px-4',
|
||||
sm: 'h-6 px-2',
|
||||
lg: 'h-12 px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
ref,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
ref?: React.RefObject<HTMLButtonElement>;
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
69
apps/public/src/components/ui/slider.tsx
Normal file
69
apps/public/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
setMatches(media.matches);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
const Slider = ({
|
||||
ref,
|
||||
className,
|
||||
tooltip,
|
||||
...props
|
||||
}: {
|
||||
ref?: any;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
value: number[];
|
||||
max: number;
|
||||
step: number;
|
||||
onValueChange: (value: number[]) => void;
|
||||
}) => {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<div className="text-sm text-muted-foreground mb-4">{tooltip}</div>
|
||||
)}
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-white/90" />
|
||||
</SliderPrimitive.Track>
|
||||
{tooltip && !isMobile ? (
|
||||
<Tooltip open disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="rounded-full bg-black text-white/70 py-1 text-xs border-white/30"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
)}
|
||||
</SliderPrimitive.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
51
apps/public/src/components/ui/tooltip.tsx
Normal file
51
apps/public/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
const TooltipContent = ({
|
||||
ref,
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof TooltipPrimitive.Content>>;
|
||||
}) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
export const Tooltiper = ({
|
||||
children,
|
||||
content,
|
||||
delayDuration = 0,
|
||||
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
delayDuration?: number;
|
||||
}) => (
|
||||
<Tooltip delayDuration={delayDuration}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent {...props}>{content}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
6
apps/public/src/components/web-sdk-config.mdx
Normal file
6
apps/public/src/components/web-sdk-config.mdx
Normal file
@@ -0,0 +1,6 @@
|
||||
##### Web options
|
||||
|
||||
- `trackScreenViews` - If true, the library will automatically track screen views (default: false)
|
||||
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
|
||||
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)
|
||||
|
||||
73
apps/public/src/components/window-image.tsx
Normal file
73
apps/public/src/components/window-image.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { FeatureCardContainer } from './feature-card';
|
||||
|
||||
interface WindowImageProps {
|
||||
src?: string;
|
||||
srcDark?: string;
|
||||
srcLight?: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function WindowImage({
|
||||
src,
|
||||
srcDark,
|
||||
srcLight,
|
||||
alt,
|
||||
caption,
|
||||
className,
|
||||
}: WindowImageProps) {
|
||||
// If src is provided, use it for both (backward compatibility)
|
||||
// Otherwise, use srcDark and srcLight
|
||||
const darkSrc = srcDark || src;
|
||||
const lightSrc = srcLight || src;
|
||||
|
||||
if (!darkSrc || !lightSrc) {
|
||||
throw new Error(
|
||||
'WindowImage requires either src or both srcDark and srcLight',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureCardContainer
|
||||
className={cn([
|
||||
'overflow-hidden rounded-lg border border-border bg-background shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden p-2 bg-card/80 border col gap-2 relative">
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="size-2 rounded-full bg-red-500" />
|
||||
<div className="size-2 rounded-full bg-yellow-500" />
|
||||
<div className="size-2 rounded-full bg-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full border rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={darkSrc}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="hidden dark:block w-full h-auto"
|
||||
/>
|
||||
<Image
|
||||
src={lightSrc}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="dark:hidden w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption className="text-center text-sm text-muted-foreground max-w-lg mx-auto">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
213
apps/public/src/lib/compare.ts
Normal file
213
apps/public/src/lib/compare.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface CompareSeo {
|
||||
title: string;
|
||||
description: string;
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareCta {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface CompareHero {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
export interface CompareCompetitor {
|
||||
name: string;
|
||||
logo: string;
|
||||
url: string;
|
||||
short_description: string;
|
||||
founded?: number;
|
||||
headquarters?: string;
|
||||
}
|
||||
|
||||
export interface CompareSummary {
|
||||
title: string;
|
||||
intro: string;
|
||||
one_liner: string;
|
||||
best_for_openpanel: string[];
|
||||
best_for_competitor: string[];
|
||||
}
|
||||
|
||||
export interface CompareHighlight {
|
||||
label: string;
|
||||
openpanel: string;
|
||||
competitor: string;
|
||||
}
|
||||
|
||||
export interface CompareHighlights {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareHighlight[];
|
||||
}
|
||||
|
||||
export interface CompareFeature {
|
||||
name: string;
|
||||
openpanel: boolean | string;
|
||||
competitor: boolean | string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CompareFeatureGroup {
|
||||
group: string;
|
||||
features: CompareFeature[];
|
||||
}
|
||||
|
||||
export interface CompareFeatureComparison {
|
||||
title: string;
|
||||
intro: string;
|
||||
groups: CompareFeatureGroup[];
|
||||
}
|
||||
|
||||
export interface ComparePricing {
|
||||
title: string;
|
||||
intro: string;
|
||||
openpanel: {
|
||||
model: string;
|
||||
description: string;
|
||||
};
|
||||
competitor: {
|
||||
model: string;
|
||||
description: string;
|
||||
free_tier?: string;
|
||||
pricing_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompareTrust {
|
||||
data_processing: string;
|
||||
data_location: string;
|
||||
self_hosting: boolean;
|
||||
}
|
||||
|
||||
export interface CompareTrustCompliance {
|
||||
title: string;
|
||||
intro: string;
|
||||
openpanel: CompareTrust;
|
||||
competitor: CompareTrust;
|
||||
}
|
||||
|
||||
export interface CompareUseCase {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface CompareUseCases {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareUseCase[];
|
||||
}
|
||||
|
||||
export interface CompareFaq {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface CompareFaqs {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareFaq[];
|
||||
}
|
||||
|
||||
export interface CompareBenefitsSection {
|
||||
label?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cta?: CompareCta;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
export interface CompareTechnicalItem {
|
||||
label: string;
|
||||
openpanel: string | string[];
|
||||
competitor: string | string[];
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CompareTechnicalComparison {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareTechnicalItem[];
|
||||
}
|
||||
|
||||
export interface CompareMigrationStep {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CompareMigration {
|
||||
title: string;
|
||||
intro: string;
|
||||
difficulty: string;
|
||||
estimated_time: string;
|
||||
steps: CompareMigrationStep[];
|
||||
sdk_compatibility: {
|
||||
similar_api: boolean;
|
||||
notes: string;
|
||||
};
|
||||
historical_data: {
|
||||
can_import: boolean;
|
||||
notes: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompareData {
|
||||
url: string;
|
||||
slug: string;
|
||||
page_type: 'alternative' | 'vs';
|
||||
seo: CompareSeo;
|
||||
hero: CompareHero;
|
||||
competitor: CompareCompetitor;
|
||||
summary_comparison: CompareSummary;
|
||||
highlights: CompareHighlights;
|
||||
feature_comparison: CompareFeatureComparison;
|
||||
technical_comparison?: CompareTechnicalComparison;
|
||||
pricing: ComparePricing;
|
||||
migration?: CompareMigration;
|
||||
trust_and_compliance?: CompareTrustCompliance;
|
||||
use_cases: CompareUseCases;
|
||||
faqs: CompareFaqs;
|
||||
benefits_section?: CompareBenefitsSection;
|
||||
ctas: {
|
||||
primary: CompareCta;
|
||||
secondary: CompareCta;
|
||||
};
|
||||
}
|
||||
|
||||
const contentDir = join(process.cwd(), 'content', 'compare');
|
||||
|
||||
export async function getCompareData(
|
||||
slug: string,
|
||||
): Promise<CompareData | null> {
|
||||
try {
|
||||
const filePath = join(contentDir, `${slug}.json`);
|
||||
const fileContents = readFileSync(filePath, 'utf8');
|
||||
const data = JSON.parse(fileContents) as CompareData;
|
||||
return {
|
||||
...data,
|
||||
url: `/compare/${slug}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading compare data for ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllCompareSlugs(): Promise<string[]> {
|
||||
try {
|
||||
const files = readdirSync(contentDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => file.replace('.json', ''));
|
||||
} catch (error) {
|
||||
console.error('Error reading compare directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
37
apps/public/src/lib/dark-mode.ts
Normal file
37
apps/public/src/lib/dark-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useIsDarkMode() {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Check localStorage first
|
||||
const savedTheme = window.localStorage.getItem('theme');
|
||||
if (savedTheme !== null) {
|
||||
return savedTheme === 'dark';
|
||||
}
|
||||
|
||||
// Fall back to system preference
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user prefers dark mode
|
||||
const htmlElement = document.documentElement;
|
||||
setIsDarkMode(htmlElement.classList.contains('dark'));
|
||||
|
||||
// Create observer to watch for class changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(htmlElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing class changes on html element
|
||||
observer.observe(htmlElement, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
10
apps/public/src/lib/github.ts
Normal file
10
apps/public/src/lib/github.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export async function getGithubRepoInfo() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://api.github.com/repos/Openpanel-dev/openpanel',
|
||||
);
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
apps/public/src/lib/layout.shared.tsx
Normal file
39
apps/public/src/lib/layout.shared.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
export const siteName = 'OpenPanel';
|
||||
export const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'https://openpanel.dev'
|
||||
: 'http://localhost:3000';
|
||||
export const url = (path: string) => {
|
||||
if (path.startsWith('http')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${baseUrl}${path}`;
|
||||
};
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: siteName,
|
||||
},
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export const getAuthor = (author?: string) => {
|
||||
return authors.find((a) => a.name === author)!;
|
||||
};
|
||||
84
apps/public/src/lib/metadata.ts
Normal file
84
apps/public/src/lib/metadata.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { url as baseUrl } from './layout.shared';
|
||||
|
||||
const siteName = 'OpenPanel';
|
||||
const defaultDescription =
|
||||
'OpenPanel is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.';
|
||||
const defaultImage = baseUrl('/ogimage.png');
|
||||
|
||||
export function getOgImageUrl(url: string): string {
|
||||
return `/og/${url.replace(baseUrl('/'), '/')}`;
|
||||
}
|
||||
|
||||
export function getRootMetadata(): Metadata {
|
||||
return getRawMetadata({
|
||||
url: baseUrl('/'),
|
||||
title: `${siteName} | An open-source alternative to Mixpanel`,
|
||||
description: defaultDescription,
|
||||
image: defaultImage,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPageMetadata({
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
}: {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}): Metadata {
|
||||
return getRawMetadata({
|
||||
url,
|
||||
title: `${title} | ${siteName}`,
|
||||
description,
|
||||
image: image ?? getOgImageUrl(url),
|
||||
});
|
||||
}
|
||||
|
||||
export function getRawMetadata(
|
||||
{
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
}: { url: string; title: string; description: string; image: string },
|
||||
meta: Metadata = {},
|
||||
): Metadata {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: baseUrl(url),
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
siteName: siteName,
|
||||
url: baseUrl(url),
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: image,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
...meta,
|
||||
};
|
||||
}
|
||||
73
apps/public/src/lib/source.ts
Normal file
73
apps/public/src/lib/source.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
articleCollection,
|
||||
docs,
|
||||
pageCollection,
|
||||
} from 'fumadocs-mdx:collections/server';
|
||||
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
|
||||
import type { CompareData } from './compare';
|
||||
import { url } from './layout.shared';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: '/docs',
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
export const articleSource = loader({
|
||||
baseUrl: '/articles',
|
||||
source: toFumadocsSource(articleCollection, []),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
export const pageSource = loader({
|
||||
baseUrl: '/',
|
||||
source: toFumadocsSource(pageCollection, []),
|
||||
});
|
||||
|
||||
export function getPageImage(page: InferPageType<typeof source>) {
|
||||
const segments = [...page.slugs, 'image.png'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `/og/docs/${segments.join('/')}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title}
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const contentDir = path.join(__dirname, '../../content/compare');
|
||||
|
||||
const files = fs
|
||||
.readdirSync(contentDir)
|
||||
.filter((file) => file.endsWith('.json'));
|
||||
|
||||
export const compareSource: CompareData[] = files
|
||||
.map((file) => {
|
||||
const filePath = path.join(contentDir, file);
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||
try {
|
||||
return JSON.parse(fileContents) as CompareData;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing compare data for ${file}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.flatMap((item) => (item ? [item] : []))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
url: `/compare/${item.slug}`,
|
||||
}));
|
||||
16
apps/public/src/lib/utils.ts
Normal file
16
apps/public/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const formatEventsCount = (value: number) => {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
33
apps/public/src/mdx-components.tsx
Normal file
33
apps/public/src/mdx-components.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import * as FilesComponents from 'fumadocs-ui/components/files';
|
||||
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import * as icons from 'lucide-react';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...(icons as unknown as MDXComponents),
|
||||
...defaultMdxComponents,
|
||||
...TabsComponents,
|
||||
...FilesComponents,
|
||||
Accordion,
|
||||
Accordions,
|
||||
...components,
|
||||
} satisfies MDXComponents;
|
||||
}
|
||||
|
||||
declare module 'mdx/types.js' {
|
||||
// Augment the MDX types to make it understand React.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace JSX {
|
||||
type Element = React.JSX.Element;
|
||||
type ElementClass = React.JSX.ElementClass;
|
||||
type ElementType = React.JSX.ElementType;
|
||||
type IntrinsicElements = React.JSX.IntrinsicElements;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||
}
|
||||
Reference in New Issue
Block a user