public: feature pages
This commit is contained in:
@@ -34,7 +34,13 @@ export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
|
||||
<div className="row gap-4">
|
||||
<GetStartedButton />
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href={'https://demo.openpanel.dev'}>See live demo</Link>
|
||||
<Link
|
||||
href={'https://demo.openpanel.dev'}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
See live demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Perks
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import type { FeatureCapability } from '@/lib/features';
|
||||
import { ZapIcon } from 'lucide-react';
|
||||
|
||||
interface CapabilitiesProps {
|
||||
title: string;
|
||||
intro?: string;
|
||||
capabilities: FeatureCapability[];
|
||||
}
|
||||
|
||||
export function Capabilities({ title, intro, capabilities }: CapabilitiesProps) {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={intro}
|
||||
variant="sm"
|
||||
className="mb-12"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{capabilities.map((cap) => (
|
||||
<FeatureCard
|
||||
key={cap.title}
|
||||
title={cap.title}
|
||||
description={cap.description}
|
||||
icon={ZapIcon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FaqItem, Faqs } from '@/components/faq';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import type { FeatureFaqs } from '@/lib/features';
|
||||
import Script from 'next/script';
|
||||
|
||||
interface FeatureFaqProps {
|
||||
faqs: FeatureFaqs;
|
||||
}
|
||||
|
||||
export function FeatureFaq({ faqs }: FeatureFaqProps) {
|
||||
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="feature-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,47 @@
|
||||
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 { FeatureHero as FeatureHeroData } from '@/lib/features';
|
||||
import { CheckCircle2Icon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface FeatureHeroProps {
|
||||
hero: FeatureHeroData;
|
||||
}
|
||||
|
||||
export function FeatureHero({ hero }: FeatureHeroProps) {
|
||||
return (
|
||||
<HeroContainer divider={false} className="-mb-32">
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
See live demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Perks
|
||||
className="flex gap-4 flex-wrap"
|
||||
perks={hero.badges.map((badge) => ({
|
||||
text: badge,
|
||||
icon: CheckCircle2Icon,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import type { FeatureUseCases } from '@/lib/features';
|
||||
|
||||
interface FeatureUseCasesProps {
|
||||
useCases: FeatureUseCases;
|
||||
}
|
||||
|
||||
export function FeatureUseCasesSection({ useCases }: FeatureUseCasesProps) {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title={useCases.title}
|
||||
description={useCases.intro}
|
||||
variant="sm"
|
||||
className="mb-12"
|
||||
/>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{useCases.items.map((useCase) => (
|
||||
<div
|
||||
key={useCase.title}
|
||||
className="col gap-2 p-6 border rounded-2xl bg-card/50"
|
||||
>
|
||||
<h3 className="font-semibold">{useCase.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{useCase.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import type { FeatureHowItWorks } from '@/lib/features';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HowItWorksProps {
|
||||
data: FeatureHowItWorks;
|
||||
}
|
||||
|
||||
export function HowItWorks({ data }: HowItWorksProps) {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title={data.title}
|
||||
description={data.intro}
|
||||
variant="sm"
|
||||
className="mb-12"
|
||||
/>
|
||||
<div className="relative">
|
||||
{data.steps.map((step, index) => (
|
||||
<div
|
||||
key={step.title}
|
||||
className="relative flex gap-4 mb-8 last:mb-0 min-w-0"
|
||||
>
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < data.steps.length - 1 && (
|
||||
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 pt-1 min-w-0 pb-8">
|
||||
<h3 className="font-semibold text-foreground mb-1">{step.title}</h3>
|
||||
<p className={cn('text-muted-foreground text-sm')}>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FeatureCardContainer } from '@/components/feature-card';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import type { RelatedFeature } from '@/lib/features';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RelatedFeaturesProps {
|
||||
title?: string;
|
||||
related: RelatedFeature[];
|
||||
}
|
||||
|
||||
export function RelatedFeatures({
|
||||
title = 'Related features',
|
||||
related,
|
||||
}: RelatedFeaturesProps) {
|
||||
if (related.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description="Explore more capabilities that work together with this feature."
|
||||
variant="sm"
|
||||
className="mb-12"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{related.map((item) => (
|
||||
<Link key={item.slug} href={`/features/${item.slug}`}>
|
||||
<FeatureCardContainer>
|
||||
<div className="row gap-3 items-center">
|
||||
<div className="col gap-1 flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
{item.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{item.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>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import type { FeatureDefinition } from '@/lib/features';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Markdown from 'react-markdown';
|
||||
|
||||
interface WhatItIsProps {
|
||||
definition: FeatureDefinition;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WhatItIs({ definition, className }: WhatItIsProps) {
|
||||
return (
|
||||
<Section className={cn('container', className)}>
|
||||
{definition.title && (
|
||||
<SectionHeader title={definition.title} variant="sm" className="mb-8" />
|
||||
)}
|
||||
<div className="prose prose-lg max-w-3xl text-muted-foreground [&_strong]:text-foreground">
|
||||
<Markdown>{definition.text}</Markdown>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
142
apps/public/src/app/(content)/features/[slug]/page.tsx
Normal file
142
apps/public/src/app/(content)/features/[slug]/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||
import { WindowImage } from '@/components/window-image';
|
||||
import {
|
||||
type FeatureData,
|
||||
getAllFeatureSlugs,
|
||||
getFeatureData,
|
||||
} from '@/lib/features';
|
||||
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 { Capabilities } from './_components/capabilities';
|
||||
import { FeatureFaq } from './_components/feature-faq';
|
||||
import { FeatureHero } from './_components/feature-hero';
|
||||
import { FeatureUseCasesSection } from './_components/feature-use-cases';
|
||||
import { HowItWorks } from './_components/how-it-works';
|
||||
import { RelatedFeatures } from './_components/related-features';
|
||||
import { WhatItIs } from './_components/what-it-is';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllFeatureSlugs();
|
||||
return slugs.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const data = await getFeatureData(slug);
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
title: 'Feature Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return getPageMetadata({
|
||||
title: data.seo.title,
|
||||
description: data.seo.description,
|
||||
url: data.url,
|
||||
image: getOgImageUrl(data.url),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FeaturePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const data = await getFeatureData(slug);
|
||||
|
||||
if (!data) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const pageUrl = url(`/features/${slug}`);
|
||||
const capabilitiesSection = data.capabilities_section ?? {
|
||||
title: 'What you can do',
|
||||
intro: undefined,
|
||||
};
|
||||
|
||||
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
|
||||
id="feature-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<FeatureHero hero={data.hero} />
|
||||
|
||||
{data.screenshots[0] && (
|
||||
<div className="container my-16">
|
||||
<WindowImage {...data.screenshots[0]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WhatItIs definition={data.definition} />
|
||||
|
||||
<Capabilities
|
||||
title={capabilitiesSection.title}
|
||||
intro={capabilitiesSection.intro}
|
||||
capabilities={data.capabilities}
|
||||
/>
|
||||
|
||||
{data.screenshots[1] && (
|
||||
<div className="container my-16">
|
||||
<WindowImage {...data.screenshots[1]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.how_it_works && (
|
||||
<div id="how-it-works">
|
||||
<HowItWorks data={data.how_it_works} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.screenshots[2] && (
|
||||
<div className="container my-16">
|
||||
<WindowImage {...data.screenshots[2]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="use-cases">
|
||||
<FeatureUseCasesSection useCases={data.use_cases} />
|
||||
</div>
|
||||
|
||||
<RelatedFeatures related={data.related_features} />
|
||||
|
||||
<div id="faq">
|
||||
<FeatureFaq faqs={data.faqs} />
|
||||
</div>
|
||||
|
||||
<CtaBanner
|
||||
title="Ready to get started?"
|
||||
description="Track events in minutes. Free 30-day trial, no credit card required."
|
||||
ctaText={data.cta.label}
|
||||
ctaLink={data.cta.href}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FeatureCardContainer } from '@/components/feature-card';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface FeatureCardLinkProps {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function FeatureCardLink({
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
}: FeatureCardLinkProps) {
|
||||
return (
|
||||
<Link href={url}>
|
||||
<FeatureCardContainer>
|
||||
<div className="row gap-3 items-center">
|
||||
<div className="col gap-1 flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
68
apps/public/src/app/(content)/features/page.tsx
Normal file
68
apps/public/src/app/(content)/features/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||
import { FeatureHero } from '@/app/(content)/features/[slug]/_components/feature-hero';
|
||||
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 { featureSource } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import { FeatureCardLink } from './_components/feature-card';
|
||||
|
||||
export const metadata: Metadata = getPageMetadata({
|
||||
title: 'Product analytics features',
|
||||
description:
|
||||
'Explore OpenPanel features: event tracking, funnels, retention, user profiles, and more. Privacy-first product analytics that just works.',
|
||||
url: url('/features'),
|
||||
image: getOgImageUrl('/features'),
|
||||
});
|
||||
|
||||
const heroData = {
|
||||
heading: 'Product analytics features',
|
||||
subheading:
|
||||
'Everything you need to understand user behavior, conversion, and retention. Simple event-based analytics without the complexity.',
|
||||
badges: ['Privacy-first', 'No cookies required', 'Real-time data'],
|
||||
};
|
||||
|
||||
export default async function FeaturesIndexPage() {
|
||||
const features = featureSource;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FeatureHero hero={heroData} />
|
||||
|
||||
<div className="container my-16">
|
||||
<WindowImage
|
||||
srcDark="/screenshots/overview-dark.webp"
|
||||
srcLight="/screenshots/overview-light.webp"
|
||||
alt="OpenPanel Dashboard Overview"
|
||||
caption="Get a clear view of your product analytics with real-time insights and customizable dashboards."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title="All features"
|
||||
description="Browse our capabilities. Each feature is designed to answer specific questions about your product and users."
|
||||
variant="sm"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-12">
|
||||
{features.map((feature) => (
|
||||
<FeatureCardLink
|
||||
key={feature.slug}
|
||||
url={feature.url}
|
||||
title={feature.hero.heading}
|
||||
description={feature.hero.subheading}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader, SectionLabel } from '@/components/section';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
ChevronRightIcon,
|
||||
DollarSignIcon,
|
||||
GlobeIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { ProductAnalyticsIllustration } from './illustrations/product-analytics';
|
||||
import { WebAnalyticsIllustration } from './illustrations/web-analytics';
|
||||
|
||||
@@ -15,18 +16,30 @@ const features = [
|
||||
description:
|
||||
'Track revenue from your payments and get insights into your revenue sources.',
|
||||
icon: DollarSignIcon,
|
||||
link: {
|
||||
href: '/features/revenue-tracking',
|
||||
children: 'More about revenue',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Profiles & Sessions',
|
||||
description:
|
||||
'Track individual users and their complete journey across your platform.',
|
||||
icon: GlobeIcon,
|
||||
link: {
|
||||
href: '/features/identify-users',
|
||||
children: 'Identify your users',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Event Tracking',
|
||||
description:
|
||||
'Capture every important interaction with flexible event tracking.',
|
||||
icon: BarChart3Icon,
|
||||
link: {
|
||||
href: '/features/event-tracking',
|
||||
children: 'All about tracking',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -62,9 +75,19 @@ export function AnalyticsInsights() {
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
link={feature.link}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-8 text-center">
|
||||
<Link
|
||||
href="/features"
|
||||
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Explore all features
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</Link>
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Section, SectionHeader } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ChevronRightIcon,
|
||||
DollarSignIcon,
|
||||
LayoutDashboardIcon,
|
||||
RocketIcon,
|
||||
@@ -18,18 +19,21 @@ const features = [
|
||||
description:
|
||||
'See your data in a visual way. You can create advanced reports and more to understand',
|
||||
icon: ChartBarIcon,
|
||||
slug: 'data-visualization',
|
||||
},
|
||||
{
|
||||
title: 'Share & Collaborate',
|
||||
description:
|
||||
'Build interactive dashboards and share insights with your team. Export reports, set up notifications, and keep everyone aligned.',
|
||||
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.',
|
||||
icon: LayoutDashboardIcon,
|
||||
slug: 'share-and-collaborate',
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
description:
|
||||
'Get notified when new events are created, or forward specific events to your own systems with our east to use integrations.',
|
||||
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.',
|
||||
icon: WorkflowIcon,
|
||||
slug: 'integrations',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -48,7 +52,11 @@ export function Collaboration() {
|
||||
|
||||
<div className="col gap-6 mt-16">
|
||||
{features.map((feature) => (
|
||||
<div className="col gap-2" key={feature.title}>
|
||||
<Link
|
||||
href={`/features/${feature.slug}`}
|
||||
className="group relative col gap-2 pr-10 overflow-hidden"
|
||||
key={feature.title}
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
<feature.icon className="size-6 inline-block mr-2 relative -top-0.5" />
|
||||
{feature.title}
|
||||
@@ -56,7 +64,11 @@ export function Collaboration() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="absolute right-0 top-1/2 size-5 -translate-y-1/2 text-muted-foreground transition-transform duration-200 translate-x-full group-hover:translate-x-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAllCompareSlugs, getCompareData } from '@/lib/compare';
|
||||
import { getFeatureData } from '@/lib/features';
|
||||
import { url as baseUrl } from '@/lib/layout.shared';
|
||||
import { articleSource, guideSource, pageSource, source } from '@/lib/source';
|
||||
import { ImageResponse } from 'next/og';
|
||||
@@ -83,6 +84,22 @@ async function getOgData(
|
||||
description: 'Step-by-step tutorials for adding analytics to your app',
|
||||
};
|
||||
}
|
||||
case 'features': {
|
||||
const slug = segments[1];
|
||||
if (!slug) {
|
||||
return {
|
||||
title: 'Product analytics features',
|
||||
description:
|
||||
'Explore OpenPanel features: event tracking, funnels, retention, user profiles, and more.',
|
||||
};
|
||||
}
|
||||
const featureData = await getFeatureData(slug);
|
||||
return {
|
||||
title: featureData?.seo.title ?? 'Feature Not Found',
|
||||
description:
|
||||
featureData?.seo.description ?? featureData?.hero.subheading,
|
||||
};
|
||||
}
|
||||
case 'docs': {
|
||||
const data = await source.getPage(segments.slice(1));
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { url } from '@/lib/layout.shared';
|
||||
import {
|
||||
articleSource,
|
||||
compareSource,
|
||||
featureSource,
|
||||
guideSource,
|
||||
pageSource,
|
||||
source,
|
||||
@@ -44,6 +45,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url('/features'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: url('/supporter'),
|
||||
lastModified: new Date(),
|
||||
@@ -77,5 +84,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
...featureSource.map((item) => ({
|
||||
url: url(item.url),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface FeatureCardProps {
|
||||
link?: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
illustration?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -50,6 +55,7 @@ export function FeatureCard({
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
link,
|
||||
}: FeatureCardProps) {
|
||||
if (illustration) {
|
||||
return (
|
||||
@@ -60,6 +66,14 @@ export function FeatureCard({
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
{link && (
|
||||
<Link
|
||||
className="mx-6 text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
href={link.href}
|
||||
>
|
||||
{link.children}
|
||||
</Link>
|
||||
)}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +86,14 @@ export function FeatureCard({
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
{link && (
|
||||
<Link
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
href={link.href}
|
||||
>
|
||||
{link.children}
|
||||
</Link>
|
||||
)}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TOOLS } from '@/app/tools/tools';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { articleSource, compareSource } from '@/lib/source';
|
||||
import { articleSource, compareSource, featureSource } from '@/lib/source';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Logo } from './logo';
|
||||
@@ -31,6 +30,17 @@ export async function Footer() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-5" />
|
||||
<h3 className="font-medium">Features</h3>
|
||||
<Links
|
||||
data={[
|
||||
{ title: 'All features', url: '/features' },
|
||||
...featureSource.map((item) => ({
|
||||
title: item.short_name,
|
||||
url: item.url,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
|
||||
@@ -33,7 +33,7 @@ export function WindowImage({
|
||||
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',
|
||||
'overflow-hidden rounded-lg border border-border bg-foreground/10 shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
|
||||
162
apps/public/src/lib/features.ts
Normal file
162
apps/public/src/lib/features.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface FeatureSeo {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface FeatureHero {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
export interface FeatureDefinition {
|
||||
title?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FeatureCapability {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface FeatureCapabilitiesSection {
|
||||
title: string;
|
||||
intro?: string;
|
||||
}
|
||||
|
||||
export interface FeatureScreenshot {
|
||||
src?: string;
|
||||
srcDark?: string;
|
||||
srcLight?: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface FeatureHowItWorksStep {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FeatureHowItWorks {
|
||||
title: string;
|
||||
intro?: string;
|
||||
steps: FeatureHowItWorksStep[];
|
||||
}
|
||||
|
||||
export interface FeatureUseCase {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface FeatureUseCases {
|
||||
title: string;
|
||||
intro?: string;
|
||||
items: FeatureUseCase[];
|
||||
}
|
||||
|
||||
export interface RelatedFeature {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FeatureFaq {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface FeatureFaqs {
|
||||
title: string;
|
||||
intro?: string;
|
||||
items: FeatureFaq[];
|
||||
}
|
||||
|
||||
export interface FeatureCta {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface FeatureData {
|
||||
url: string;
|
||||
slug: string;
|
||||
/** Short internal name for nav, footer, etc. (e.g. "Event tracking") */
|
||||
short_name: string;
|
||||
seo: FeatureSeo;
|
||||
hero: FeatureHero;
|
||||
definition: FeatureDefinition;
|
||||
capabilities: FeatureCapability[];
|
||||
capabilities_section?: FeatureCapabilitiesSection;
|
||||
screenshots: FeatureScreenshot[];
|
||||
how_it_works?: FeatureHowItWorks;
|
||||
use_cases: FeatureUseCases;
|
||||
related_features: RelatedFeature[];
|
||||
faqs: FeatureFaqs;
|
||||
cta: FeatureCta;
|
||||
}
|
||||
|
||||
const contentDir = join(process.cwd(), 'content', 'features');
|
||||
|
||||
export async function getFeatureData(
|
||||
slug: string,
|
||||
): Promise<FeatureData | null> {
|
||||
try {
|
||||
const filePath = join(contentDir, `${slug}.json`);
|
||||
const fileContents = readFileSync(filePath, 'utf8');
|
||||
const data = JSON.parse(fileContents) as Omit<FeatureData, 'url'>;
|
||||
return {
|
||||
...data,
|
||||
url: `/features/${slug}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading feature data for ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllFeatureSlugs(): 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 features directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFeatureSource(): Promise<FeatureData[]> {
|
||||
const slugs = await getAllFeatureSlugs();
|
||||
const results: FeatureData[] = [];
|
||||
for (const slug of slugs) {
|
||||
const data = await getFeatureData(slug);
|
||||
if (data) results.push(data);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Sync loader for use in source.ts (same pattern as compareSource). */
|
||||
export function loadFeatureSourceSync(): FeatureData[] {
|
||||
try {
|
||||
const files = readdirSync(contentDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => {
|
||||
const slug = file.replace('.json', '');
|
||||
const filePath = join(contentDir, file);
|
||||
const fileContents = readFileSync(filePath, 'utf8');
|
||||
const data = JSON.parse(fileContents) as Omit<FeatureData, 'url'>;
|
||||
return { ...data, url: `/features/${slug}` };
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
console.error('Error loading feature source:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ 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 type { FeatureData } from './features';
|
||||
import { loadFeatureSourceSync } from './features';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
@@ -91,3 +93,5 @@ function loadCompareSource(): CompareData[] {
|
||||
}
|
||||
|
||||
export const compareSource: CompareData[] = loadCompareSource();
|
||||
|
||||
export const featureSource: FeatureData[] = loadFeatureSourceSync();
|
||||
|
||||
Reference in New Issue
Block a user