public: add more content

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-25 22:27:04 +01:00
parent f311146ade
commit 38d9b65ec8
42 changed files with 1864 additions and 315 deletions

View File

@@ -1,8 +1,7 @@
import { FeatureCardContainer } from '@/components/feature-card';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import { Section, SectionHeader } from '@/components/section';
import type { RelatedLinks } from '@/lib/compare';
import { ArrowRightIcon, BookOpenIcon, GitCompareIcon } from 'lucide-react';
import Link from 'next/link';
interface RelatedLinksProps {
relatedLinks?: RelatedLinks;
@@ -11,7 +10,7 @@ interface RelatedLinksProps {
export function RelatedLinksSection({ relatedLinks }: RelatedLinksProps) {
if (
!relatedLinks ||
(!relatedLinks.articles?.length && !relatedLinks.alternatives?.length)
(!relatedLinks.guides?.length && !relatedLinks.articles?.length && !relatedLinks.alternatives?.length)
) {
return null;
}
@@ -19,59 +18,60 @@ export function RelatedLinksSection({ relatedLinks }: RelatedLinksProps) {
return (
<Section className="container">
<SectionHeader
description="Explore more comparisons and guides to help you choose the right analytics tool."
title="Related resources"
description="Explore more comparisons and guides to help you choose the right analytics tool"
variant="sm"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-12">
{relatedLinks.articles && relatedLinks.articles.length > 0 && (
<div className="mt-12 grid gap-8 md:grid-cols-3">
{relatedLinks.guides && relatedLinks.guides.length > 0 && (
<div className="col gap-4">
<div className="row gap-2 items-center mb-2">
<BookOpenIcon className="size-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">Articles</h3>
</div>
<div className="col gap-3">
{relatedLinks.articles.map((article) => (
<Link key={article.url} href={article.url}>
<FeatureCardContainer className="hover:border-primary/30 transition-colors">
<div className="row gap-3 items-center">
<div className="col gap-1 flex-1 min-w-0">
<h4 className="text-base font-semibold group-hover:text-primary transition-colors">
{article.title}
</h4>
</div>
<ArrowRightIcon className="opacity-0 group-hover:opacity-100 size-4 shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all duration-300" />
</div>
</FeatureCardContainer>
</Link>
))}
</div>
<h3 className="font-semibold text-muted-foreground text-sm uppercase tracking-wider">
Guides
</h3>
{relatedLinks.guides.map((guide) => (
<Link
className="row items-center gap-2 text-sm transition-colors hover:text-primary"
href={guide.url}
key={guide.url}
>
<ArrowRightIcon className="size-4 shrink-0" />
{guide.title}
</Link>
))}
</div>
)}
{relatedLinks.articles && relatedLinks.articles.length > 0 && (
<div className="col gap-4">
<h3 className="font-semibold text-muted-foreground text-sm uppercase tracking-wider">
Articles
</h3>
{relatedLinks.articles.map((article) => (
<Link
className="row items-center gap-2 text-sm transition-colors hover:text-primary"
href={article.url}
key={article.url}
>
<ArrowRightIcon className="size-4 shrink-0" />
{article.title}
</Link>
))}
</div>
)}
{relatedLinks.alternatives && relatedLinks.alternatives.length > 0 && (
<div className="col gap-4">
<div className="row gap-2 items-center mb-2">
<GitCompareIcon className="size-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">Other comparisons</h3>
</div>
<div className="col gap-3">
{relatedLinks.alternatives.map((alternative) => (
<Link key={alternative.url} href={alternative.url}>
<FeatureCardContainer className="hover:border-primary/30 transition-colors">
<div className="row gap-3 items-center">
<div className="col gap-1 flex-1 min-w-0">
<h4 className="text-base font-semibold group-hover:text-primary transition-colors">
{alternative.name} alternative
</h4>
</div>
<ArrowRightIcon className="opacity-0 group-hover:opacity-100 size-4 shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all duration-300" />
</div>
</FeatureCardContainer>
</Link>
))}
</div>
<h3 className="font-semibold text-muted-foreground text-sm uppercase tracking-wider">
Comparisons
</h3>
{relatedLinks.alternatives.map((alternative) => (
<Link
className="row items-center gap-2 text-sm transition-colors hover:text-primary"
href={alternative.url}
key={alternative.url}
>
<ArrowRightIcon className="size-4 shrink-0" />
{alternative.name} alternative
</Link>
))}
</div>
)}
</div>

View File

@@ -0,0 +1,32 @@
import { CheckIcon } from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import type { ForBenefits as ForBenefitsData } from '@/lib/for';
interface ForBenefitsProps {
benefits: ForBenefitsData;
}
export function ForBenefits({ benefits }: ForBenefitsProps) {
return (
<Section className="container">
<SectionHeader
description={benefits.intro}
title={benefits.title}
variant="sm"
/>
<div className="col mt-12 max-w-3xl gap-4">
{benefits.items.map((benefit) => (
<div className="row items-start gap-3" key={benefit.title}>
<CheckIcon className="mt-0.5 size-5 shrink-0 text-green-500" />
<div className="col gap-1">
<h3 className="font-semibold">{benefit.title}</h3>
<p className="text-muted-foreground text-sm">
{benefit.description}
</p>
</div>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,29 @@
import { FaqItem, Faqs } from '@/components/faq';
import { Section, SectionHeader } from '@/components/section';
import type { ForFaqs } from '@/lib/for';
interface ForFaqProps {
faqs: ForFaqs;
}
export function ForFaq({ faqs }: ForFaqProps) {
return (
<Section className="container">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SectionHeader
className="mb-16"
description={faqs.intro}
title={faqs.title}
variant="sm"
/>
<Faqs>
{faqs.items.map((faq) => (
<FaqItem key={faq.question} question={faq.question}>
{faq.answer}
</FaqItem>
))}
</Faqs>
</div>
</Section>
);
}

View File

@@ -0,0 +1,32 @@
import { CheckCircle2Icon } from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import type { ForFeatures as ForFeaturesData } from '@/lib/for';
interface ForFeaturesProps {
features: ForFeaturesData;
}
export function ForFeatures({ features }: ForFeaturesProps) {
return (
<Section className="container">
<SectionHeader
description={features.intro}
title={features.title}
variant="sm"
/>
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{features.items.map((feature) => (
<div className="col gap-2 rounded-2xl border p-6" key={feature.title}>
<div className="row items-center gap-2">
<CheckCircle2Icon className="size-5 shrink-0 text-green-500" />
<h3 className="font-semibold">{feature.title}</h3>
</div>
<p className="text-muted-foreground text-sm">
{feature.description}
</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,48 @@
import { CheckCircle2Icon } from 'lucide-react';
import Link from 'next/link';
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 { ForHero as ForHeroData } from '@/lib/for';
interface ForHeroProps {
hero: ForHeroData;
tocItems?: Array<{ id: string; label: string }>;
}
export function ForHero({ hero }: ForHeroProps) {
return (
<HeroContainer className="-mb-32" divider={false}>
<div className="col gap-6">
<SectionHeader
as="h1"
className="flex-1"
description={hero.subheading}
title={hero.heading}
variant="sm"
/>
<div className="row gap-4">
<GetStartedButton />
<Button asChild size="lg" variant="outline">
<Link
href={'https://demo.openpanel.dev'}
rel="noreferrer noopener nofollow"
target="_blank"
>
See live demo
</Link>
</Button>
</div>
<Perks
className="flex flex-wrap gap-4"
perks={hero.badges.map((badge) => ({
text: badge,
icon: CheckCircle2Icon,
}))}
/>
</div>
</HeroContainer>
);
}

View File

@@ -0,0 +1,30 @@
import { XCircleIcon } from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import type { ForProblem as ForProblemData } from '@/lib/for';
interface ForProblemProps {
problem: ForProblemData;
}
export function ForProblem({ problem }: ForProblemProps) {
return (
<Section className="container">
<SectionHeader
description={problem.intro}
title={problem.title}
variant="sm"
/>
<div className="mt-12 grid gap-6 md:grid-cols-2">
{problem.items.map((item) => (
<div className="col gap-2 rounded-2xl border p-6" key={item.title}>
<div className="row items-center gap-2">
<XCircleIcon className="size-5 shrink-0 text-red-500" />
<h3 className="font-semibold">{item.title}</h3>
</div>
<p className="text-muted-foreground text-sm">{item.description}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,82 @@
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import { Section, SectionHeader } from '@/components/section';
import type { ForRelatedLinks } from '@/lib/for';
interface ForRelatedLinksProps {
relatedLinks: ForRelatedLinks;
}
export function ForRelatedLinksSection({ relatedLinks }: ForRelatedLinksProps) {
const hasLinks =
relatedLinks.articles?.length ||
relatedLinks.guides?.length ||
relatedLinks.comparisons?.length;
if (!hasLinks) {
return null;
}
return (
<Section className="container">
<SectionHeader
description="Learn more about OpenPanel and how it can help you."
title="Related resources"
variant="sm"
/>
<div className="mt-12 grid gap-8 md:grid-cols-3">
{relatedLinks.guides && relatedLinks.guides.length > 0 && (
<div className="col gap-4">
<h3 className="font-semibold text-muted-foreground text-sm uppercase tracking-wider">
Guides
</h3>
{relatedLinks.guides.map((link) => (
<Link
className="row items-center gap-2 text-sm transition-colors hover:text-primary"
href={link.url}
key={link.url}
>
<ArrowRightIcon className="size-4 shrink-0" />
{link.title}
</Link>
))}
</div>
)}
{relatedLinks.articles && relatedLinks.articles.length > 0 && (
<div className="col gap-4">
<h3 className="font-semibold text-muted-foreground text-sm uppercase tracking-wider">
Articles
</h3>
{relatedLinks.articles.map((link) => (
<Link
className="row items-center gap-2 text-sm transition-colors hover:text-primary"
href={link.url}
key={link.url}
>
<ArrowRightIcon className="size-4 shrink-0" />
{link.title}
</Link>
))}
</div>
)}
{relatedLinks.comparisons && relatedLinks.comparisons.length > 0 && (
<div className="col gap-4">
<h3 className="font-semibold text-muted-foreground text-sm uppercase tracking-wider">
Comparisons
</h3>
{relatedLinks.comparisons.map((link) => (
<Link
className="row items-center gap-2 text-sm transition-colors hover:text-primary"
href={link.url}
key={link.url}
>
<ArrowRightIcon className="size-4 shrink-0" />
{link.title}
</Link>
))}
</div>
)}
</div>
</Section>
);
}

View File

@@ -0,0 +1,138 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { ForBenefits } from './_components/for-benefits';
import { ForFaq } from './_components/for-faq';
import { ForFeatures } from './_components/for-features';
import { ForHero } from './_components/for-hero';
import { ForProblem } from './_components/for-problem';
import { ForRelatedLinksSection } from './_components/for-related-links';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { WindowImage } from '@/components/window-image';
import { getAllForSlugs, getForData } from '@/lib/for';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
export async function generateStaticParams() {
const slugs = await getAllForSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const data = await getForData(slug);
if (!data) {
return {
title: 'Page Not Found',
};
}
return getPageMetadata({
title: data.seo.title,
description: data.seo.description,
url: data.url,
image: getOgImageUrl(data.url),
});
}
export default async function ForPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const data = await getForData(slug);
if (!data) {
return notFound();
}
const pageUrl = url(`/for/${slug}`);
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.webp'),
},
},
};
return (
<div>
<Script
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
id="for-schema"
strategy="beforeInteractive"
type="application/ld+json"
/>
<ForHero hero={data.hero} />
<div className="container my-16">
<WindowImage
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."
srcDark="/screenshots/overview-dark.webp"
srcLight="/screenshots/overview-light.webp"
/>
</div>
<div id="problem">
<ForProblem problem={data.problem} />
</div>
<div id="features">
<ForFeatures features={data.features} />
</div>
<div className="container my-16">
<WindowImage
alt="OpenPanel Real-time Analytics"
caption="Track events in real-time as they happen with instant updates and live monitoring."
srcDark="/screenshots/realtime-dark.webp"
srcLight="/screenshots/realtime-light.webp"
/>
</div>
<div id="benefits">
<ForBenefits benefits={data.benefits} />
</div>
<div className="container my-16">
<WindowImage
alt="OpenPanel Dashboard"
caption="Comprehensive analytics dashboard with real-time insights and customizable views."
srcDark="/screenshots/dashboard-dark.webp"
srcLight="/screenshots/dashboard-light.webp"
/>
</div>
<div id="faq">
<ForFaq faqs={data.faqs} />
</div>
{data.related_links && (
<ForRelatedLinksSection relatedLinks={data.related_links} />
)}
<CtaBanner
ctaLink={data.ctas.primary.href}
ctaText={data.ctas.primary.label}
description="Test OpenPanel free for 30 days, you'll not be charged anything unless you upgrade to a paid plan."
title="Ready to get started?"
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { ArrowRightIcon } from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { SectionHeader } from '@/components/section';
import { getAllForSlugs, getForData } from '@/lib/for';
import { getPageMetadata } from '@/lib/metadata';
export function generateMetadata(): Metadata {
return getPageMetadata({
title: 'OpenPanel for Your Use Case',
description:
'Discover how OpenPanel helps startups, developers, and agencies with privacy-first, open-source web and product analytics.',
url: '/for',
});
}
export default async function ForListPage() {
const slugs = await getAllForSlugs();
const pages = await Promise.all(
slugs.map(async (slug) => {
const data = await getForData(slug);
return data;
})
);
const validPages = pages.filter(
(page): page is NonNullable<typeof page> => page !== null
);
return (
<HeroContainer divider={false}>
<SectionHeader
as="h1"
description="See how OpenPanel helps different teams and industries with privacy-first, open-source analytics."
title="OpenPanel for Your Use Case"
variant="sm"
/>
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{validPages.map((page) => (
<Link
className="col gap-3 rounded-2xl border p-6 transition-colors hover:border-primary"
href={`/for/${page.slug}`}
key={page.slug}
>
<h2 className="font-semibold text-lg">{page.hero.heading}</h2>
<p className="line-clamp-3 text-muted-foreground text-sm">
{page.seo.description}
</p>
<div className="row mt-auto items-center gap-1 pt-2 text-primary text-sm">
Learn more <ArrowRightIcon className="size-4" />
</div>
</Link>
))}
</div>
</HeroContainer>
);
}

View File

@@ -1,8 +1,3 @@
import {
OPENPANEL_BASE_URL,
OPENPANEL_DESCRIPTION,
OPENPANEL_NAME,
} from '@/lib/openpanel-brand';
import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration';
import { CtaBanner } from './_sections/cta-banner';
@@ -13,19 +8,34 @@ import { Pricing } from './_sections/pricing';
import { Sdks } from './_sections/sdks';
import { Testimonials } from './_sections/testimonials';
import { WhyOpenPanel } from './_sections/why-openpanel';
import {
OPENPANEL_BASE_URL,
OPENPANEL_DESCRIPTION,
OPENPANEL_NAME,
OPENPANEL_SITE_NAME,
} from '@/lib/openpanel-brand';
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
name: OPENPANEL_NAME,
'@type': 'WebSite',
name: OPENPANEL_SITE_NAME,
alternateName: ['OpenPanel', 'OpenPanel.dev'],
url: OPENPANEL_BASE_URL,
sameAs: ['https://github.com/Openpanel-dev/openpanel'],
description: OPENPANEL_DESCRIPTION,
keywords: 'openpanel, product analytics, web analytics, mixpanel alternative, open source analytics',
},
{
'@type': 'Organization',
name: OPENPANEL_SITE_NAME,
url: OPENPANEL_BASE_URL,
sameAs: [
'https://github.com/Openpanel-dev/openpanel',
'https://x.com/OpenPanelDev',
],
description: OPENPANEL_DESCRIPTION,
keywords:
'openpanel, product analytics, web analytics, mixpanel alternative, open source analytics',
},
{
'@type': 'SoftwareApplication',
name: OPENPANEL_NAME,
@@ -41,8 +51,8 @@ export default function HomePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
type="application/ld+json"
/>
<Hero />
<WhyOpenPanel />

View File

@@ -165,6 +165,7 @@ export interface RelatedLink {
}
export interface RelatedLinks {
guides?: RelatedLink[];
articles?: RelatedLink[];
alternatives?: RelatedLink[];
}

114
apps/public/src/lib/for.ts Normal file
View File

@@ -0,0 +1,114 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface ForSeo {
title: string;
description: string;
noindex?: boolean;
}
export interface ForHero {
heading: string;
subheading: string;
badges: string[];
}
export interface ForProblem {
title: string;
intro: string;
items: Array<{
title: string;
description: string;
}>;
}
export interface ForFeature {
title: string;
description: string;
icon?: string;
}
export interface ForFeatures {
title: string;
intro: string;
items: ForFeature[];
}
export interface ForBenefit {
title: string;
description: string;
}
export interface ForBenefits {
title: string;
intro: string;
items: ForBenefit[];
}
export interface ForFaq {
question: string;
answer: string;
}
export interface ForFaqs {
title: string;
intro: string;
items: ForFaq[];
}
export interface ForCta {
label: string;
href: string;
}
export interface ForRelatedLinks {
articles?: Array<{ title: string; url: string }>;
guides?: Array<{ title: string; url: string }>;
comparisons?: Array<{ title: string; url: string }>;
}
export interface ForData {
url: string;
slug: string;
audience: string;
seo: ForSeo;
hero: ForHero;
problem: ForProblem;
features: ForFeatures;
benefits: ForBenefits;
faqs: ForFaqs;
related_links?: ForRelatedLinks;
ctas: {
primary: ForCta;
secondary: ForCta;
};
}
const contentDir = join(process.cwd(), 'content', 'for');
export async function getForData(slug: string): Promise<ForData | null> {
try {
const filePath = join(contentDir, `${slug}.json`);
const fileContents = readFileSync(filePath, 'utf8');
const data = JSON.parse(fileContents) as ForData;
return {
...data,
url: `/for/${slug}`,
};
} catch (error) {
console.error(`Error loading for data for ${slug}:`, error);
return null;
}
}
export async function getAllForSlugs(): 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 for directory:', error);
return [];
}
}

View File

@@ -1,8 +1,11 @@
import type { Metadata } from 'next';
import { OPENPANEL_DESCRIPTION, OPENPANEL_NAME } from './openpanel-brand';
import {
OPENPANEL_DESCRIPTION,
OPENPANEL_SITE_NAME,
} from './openpanel-brand';
import { url as baseUrl } from './layout.shared';
const siteName = OPENPANEL_NAME;
const siteName = OPENPANEL_SITE_NAME;
const defaultDescription = OPENPANEL_DESCRIPTION;
const defaultImage = baseUrl('/ogimage.png');

View File

@@ -1,4 +1,5 @@
export const OPENPANEL_NAME = 'OpenPanel';
export const OPENPANEL_SITE_NAME = 'OpenPanel Analytics';
export const OPENPANEL_BASE_URL = 'https://openpanel.dev';
export const OPENPANEL_DESCRIPTION =
'OpenPanel is an open-source web and product analytics platform, an open-source alternative to Mixpanel with optional self-hosting.';