chore:little fixes and formating and linting and patches

This commit is contained in:
2026-03-31 15:50:54 +02:00
parent a1ce71ffb6
commit 9b197abcfa
815 changed files with 22960 additions and 8982 deletions

View File

@@ -1,12 +1,12 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
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 { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
export async function generateMetadata({
params,
@@ -46,7 +46,7 @@ export default async function Page({
const page = await pageSource.getPage(pages);
const Body = page?.data.body;
if (!page || !Body) {
if (!(page && Body)) {
return notFound();
}
@@ -70,16 +70,16 @@ export default async function Page({
return (
<div>
<Script
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
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}
title={page.data.title}
/>
</HeroContainer>
<main className="container">

View File

@@ -1,3 +1,9 @@
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';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
@@ -7,16 +13,10 @@ 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 { getAuthor, url } 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();
@@ -63,8 +63,7 @@ export default async function Page({
const relatedArticles = (await articleSource.getPages())
.filter(
(item) =>
item.data.tag === article?.data.tag && item.url !== article?.url,
(item) => item.data.tag === article?.data.tag && item.url !== article?.url
)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
@@ -107,29 +106,29 @@ export default async function Page({
<HeroContainer>
<div className="col">
<Link
className="mb-4 flex items-center gap-2 text-muted-foreground"
href={goBackUrl}
className="flex items-center gap-2 mb-4 text-muted-foreground"
>
<ArrowLeftIcon className="w-4 h-4" />
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to all articles</span>
</Link>
<SectionHeader
as="h1"
title={article?.data.title}
description={article?.data.description}
title={article?.data.title}
/>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
<div className="row mt-8 items-center gap-4">
<div className="center-center size-10 rounded-full bg-black">
{author.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
alt={author.name}
width={48}
className="size-10 rounded-full object-cover"
height={48}
src={author.image}
width={48}
/>
) : (
<Logo className="w-6 h-6 fill-white" />
<Logo className="h-6 w-6 fill-white" />
)}
</div>
<div className="col">
@@ -149,23 +148,23 @@ export default async function Page({
</div>
</HeroContainer>
<Script
strategy="beforeInteractive"
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
id="article-schema"
strategy="beforeInteractive"
type="application/ld+json"
/>
<article className="container max-w-5xl col">
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
<article className="col container max-w-5xl">
<div className="grid grid-cols-1 gap-0 md:grid-cols-[1fr_300px]">
<div className="min-w-0">
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
<div className="prose [&_img]:h-auto [&_img]:max-w-full [&_table]:w-auto">
<Body components={getMDXComponents()} />
</div>
</div>
<aside className="pl-12 pb-12 gap-8 col">
<aside className="col gap-8 pb-12 pl-12">
<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">
<span className="font-semibold text-lg">Try OpenPanel</span>
<p className="mb-4 text-muted-foreground text-sm">
Give it a spin for free. No credit card required.
</p>
<GetStartedButton />
@@ -175,17 +174,17 @@ export default async function Page({
{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">
<h3 className="mb-8 font-bold text-2xl">Related articles</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{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}
key={item.url}
tag={item.data.tag}
team={item.data.team}
title={item.data.title}
url={item.url}
/>
))}
</div>

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
@@ -6,9 +7,6 @@ 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',
@@ -20,30 +18,30 @@ export const metadata: Metadata = getPageMetadata({
export default async function Page() {
const articles = (await articleSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
(a, b) => b.data.date.getTime() - a.data.date.getTime()
);
return (
<div>
<HeroContainer className="-mb-32">
<SectionHeader
as="h1"
align="center"
as="h1"
className="flex-1"
title="Articles"
description="Read our latest articles and stay up to date with the latest news and updates."
title="Articles"
/>
</HeroContainer>
<Section className="container grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
<Section className="container grid grid-cols-1 gap-8 sm:grid-cols-2 md:grid-cols-3">
{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}
key={item.url}
tag={item.data.tag}
team={item.data.team}
title={item.data.title}
url={item.url}
/>
))}
</Section>

View File

@@ -1,8 +1,8 @@
import { CheckIcon } from 'lucide-react';
import Link from 'next/link';
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;
@@ -26,23 +26,23 @@ export function BenefitsSection({
}: BenefitsSectionProps) {
return (
<Section className={cn('container', className)}>
<div className="max-w-3xl col gap-6">
<div className="col max-w-3xl gap-6">
{label && (
<p className="text-sm italic text-primary font-medium">{label}</p>
<p className="font-medium text-primary text-sm italic">{label}</p>
)}
<h2 className="text-4xl md:text-5xl font-semibold leading-tight">
<h2 className="font-semibold text-4xl leading-tight md:text-5xl">
{title}
</h2>
<p className="text-lg text-muted-foreground">{description}</p>
{cta && (
<Button size="lg" asChild className="w-fit">
<Button asChild className="w-fit" size="lg">
<Link href={cta.href}>{cta.label}</Link>
</Button>
)}
<div className="col gap-4 mt-4">
<div className="col mt-4 gap-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" />
<div className="row items-start gap-3" key={benefit}>
<CheckIcon className="mt-0.5 size-5 shrink-0 text-green-500" />
<p className="text-muted-foreground">{benefit}</p>
</div>
))}

View File

@@ -1,12 +1,12 @@
import { CheckCircle2Icon } from 'lucide-react';
import Link from 'next/link';
import { CompareToc } from './compare-toc';
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;
@@ -15,11 +15,11 @@ interface CompareHeroProps {
export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
return (
<HeroContainer divider={false} className="-mb-32">
<HeroContainer className="-mb-32" divider={false}>
<div
className={
tocItems.length > 0
? 'grid md:grid-cols-[1fr_auto] gap-8 items-start'
? 'grid items-start gap-8 md:grid-cols-[1fr_auto]'
: 'col gap-6'
}
>
@@ -27,24 +27,24 @@ export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
<SectionHeader
as="h1"
className="flex-1"
title={hero.heading}
description={hero.subheading}
title={hero.heading}
variant="sm"
/>
<div className="row gap-4">
<GetStartedButton />
<Button size="lg" variant="outline" asChild>
<Button asChild size="lg" variant="outline">
<Link
href={'https://demo.openpanel.dev'}
target="_blank"
rel="noreferrer noopener nofollow"
target="_blank"
>
See live demo
</Link>
</Button>
</div>
<Perks
className="flex gap-4 flex-wrap"
className="flex flex-wrap gap-4"
perks={hero.badges.map((badge) => ({
text: badge,
icon: CheckCircle2Icon,

View File

@@ -8,15 +8,13 @@ interface CompareOverviewProps {
export function CompareOverview({ overview }: CompareOverviewProps) {
return (
<Section className="container">
<article className="col gap-6 max-w-3xl">
<h2 className="text-3xl md:text-4xl font-semibold">
{overview.title}
</h2>
<article className="col max-w-3xl gap-6">
<h2 className="font-semibold text-3xl md:text-4xl">{overview.title}</h2>
<div className="col gap-4">
{overview.paragraphs.map((paragraph) => (
<p
className="text-base text-muted-foreground leading-relaxed md:text-lg"
key={paragraph.slice(0, 48)}
className="text-muted-foreground leading-relaxed text-base md:text-lg"
>
{paragraph}
</p>

View File

@@ -1,10 +1,10 @@
'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';
import { FeatureCardContainer } from '@/components/feature-card';
import { cn } from '@/lib/utils';
interface TocItem {
id: string;
@@ -22,17 +22,17 @@ export function CompareToc({ items, className }: CompareTocProps) {
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,
'sticky top-24 hidden h-fit w-64 shrink-0 md:block',
'col gap-3 rounded-xl border bg-background/50 p-4 backdrop-blur-sm',
className
)}
>
<nav className="col gap-1">
{items.map((item) => (
<Link
key={item.id}
className="group/toc relative flex min-h-6 items-center py-1 text-muted-foreground text-sm transition-colors duration-200 hover:text-foreground"
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"
key={item.id}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -45,8 +45,8 @@ export function CompareToc({ items, className }: CompareTocProps) {
}
}}
>
<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 className="absolute left-0 flex w-0 items-center overflow-hidden transition-all duration-300 ease-out group-hover/toc:w-5">
<ArrowRightIcon className="size-3 shrink-0 -translate-x-full transition-transform delay-75 duration-300 ease-out group-hover/toc:translate-x-0" />
</div>
<span className="transition-transform duration-300 ease-out group-hover/toc:translate-x-5">
{item.label}

View File

@@ -1,6 +1,9 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareHighlights, CompareFeatureComparison } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import type {
CompareFeatureComparison,
CompareHighlights,
} from '@/lib/compare';
import { cn } from '@/lib/utils';
interface ComparisonTableProps {
@@ -40,7 +43,7 @@ export function ComparisonTable({
openpanel: feature.openpanel,
competitor: feature.competitor,
notes: feature.notes,
})),
}))
);
const allRows = [
@@ -56,42 +59,44 @@ export function ComparisonTable({
return (
<Section className="container">
<SectionHeader
title={highlights.title}
description={highlights.intro}
title={highlights.title}
variant="sm"
/>
<div className="mt-12 border rounded-2xl overflow-hidden">
<div className="mt-12 overflow-hidden rounded-2xl border">
<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>
<th className="p-4 text-left font-semibold">Feature</th>
<th className="p-4 text-left font-semibold">OpenPanel</th>
<th className="p-4 text-left 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'
)}
key={row.feature}
>
<td className="p-4 font-medium">{row.feature}</td>
<td className="p-4">
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
{renderValue(row.openpanel)}
</div>
</td>
<td className="p-4">
<div className="col gap-1">
<div className="row gap-2 items-center text-muted-foreground">
<div className="row items-center gap-2 text-muted-foreground">
{renderValue(row.competitor)}
</div>
{row.notes && (
<span className="text-xs text-muted-foreground/70 mt-1">
<span className="mt-1 text-muted-foreground/70 text-xs">
{row.notes}
</span>
)}
@@ -106,4 +111,3 @@ export function ComparisonTable({
</Section>
);
}

View File

@@ -1,13 +1,12 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareFeatureGroup } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Section, SectionHeader } from '@/components/section';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import type { CompareFeatureGroup } from '@/lib/compare';
interface FeatureComparisonProps {
featureGroups: CompareFeatureGroup[];
@@ -21,37 +20,39 @@ function renderFeatureValue(value: boolean | string) {
<XIcon className="size-5 text-red-500" />
);
}
return <span className="text-sm text-muted-foreground">{value}</span>;
return <span className="text-muted-foreground text-sm">{value}</span>;
}
export function FeatureComparison({ featureGroups }: FeatureComparisonProps) {
return (
<Section className="container">
<SectionHeader
title="Feature comparison"
description="Detailed breakdown of capabilities"
align="center"
description="Detailed breakdown of capabilities"
title="Feature comparison"
/>
<div className="mt-12 col gap-4">
<div className="col mt-12 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">
<div className="overflow-hidden rounded-3xl border" key={group.group}>
<Accordion className="w-full" collapsible type="single">
<AccordionItem className="border-0" value={group.group}>
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<h3 className="text-lg font-semibold">{group.group}</h3>
<h3 className="font-semibold text-lg">{group.group}</h3>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6">
<div className="col gap-4">
{group.features.map((feature) => (
<div
className="grid gap-4 border-b py-3 last:border-b-0 md:grid-cols-3"
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">
<div className="font-medium text-sm">
{feature.name}
</div>
<div className="row items-center gap-2">
{renderFeatureValue(feature.openpanel)}
</div>
<div className="row gap-2 items-center text-muted-foreground">
<div className="row items-center gap-2 text-muted-foreground">
{renderFeatureValue(feature.competitor)}
</div>
</div>
@@ -66,4 +67,3 @@ export function FeatureComparison({ featureGroups }: FeatureComparisonProps) {
</Section>
);
}

View File

@@ -1,15 +1,15 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareFeatureComparison } from '@/lib/compare';
import {
BellIcon,
BrainIcon,
HeartIcon,
LayoutIcon,
LockIcon,
MessageSquareIcon,
RefreshCwIcon,
SparklesIcon,
LayoutIcon,
BellIcon,
BrainIcon,
LockIcon,
} from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import type { CompareFeatureComparison } from '@/lib/compare';
interface FeaturesShowcaseProps {
featureComparison: CompareFeatureComparison;
@@ -33,23 +33,23 @@ export function FeaturesShowcase({ featureComparison }: FeaturesShowcaseProps) {
.filter(
(f) =>
f.openpanel === true ||
(typeof f.openpanel === 'string' && f.openpanel.toLowerCase() !== 'no'),
(typeof f.openpanel === 'string' && f.openpanel.toLowerCase() !== 'no')
)
.slice(0, 8);
return (
<Section className="container">
<SectionHeader
title={featureComparison.title}
description={featureComparison.intro}
title={featureComparison.title}
variant="sm"
/>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{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">
<div className="col gap-3" key={feature.name}>
<div className="center-center size-10 rounded-lg bg-primary/10">
<Icon className="size-5 text-primary" />
</div>
<h3 className="font-semibold text-sm">{feature.name}</h3>
@@ -60,4 +60,3 @@ export function FeaturesShowcase({ featureComparison }: FeaturesShowcaseProps) {
</Section>
);
}

View File

@@ -1,6 +1,6 @@
import { CheckIcon, MinusIcon, XIcon } from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import { CompareHighlight } from '@/lib/compare';
import { CheckIcon, XIcon, MinusIcon } from 'lucide-react';
import type { CompareHighlight } from '@/lib/compare';
import { cn } from '@/lib/utils';
interface HighlightsGridProps {
@@ -22,28 +22,28 @@ export function HighlightsGrid({ highlights }: HighlightsGridProps) {
return (
<Section className="container">
<SectionHeader
title="Key differences"
description="See how OpenPanel compares at a glance"
align="center"
description="See how OpenPanel compares at a glance"
title="Key differences"
/>
<div className="mt-12 border rounded-3xl overflow-hidden">
<div className="mt-12 overflow-hidden rounded-3xl border">
<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',
'grid gap-4 p-6 md:grid-cols-3',
index % 2 === 0 ? 'bg-muted/30' : 'bg-background'
)}
key={highlight.label}
>
<div className="font-semibold text-sm md:text-base">
{highlight.label}
</div>
<div className="row gap-3 items-center">
<div className="row items-center gap-3">
{getIcon(highlight.openpanel)}
<span className="text-sm">{highlight.openpanel}</span>
</div>
<div className="row gap-3 items-center text-muted-foreground">
<div className="row items-center gap-3 text-muted-foreground">
{getIcon(highlight.competitor)}
<span className="text-sm">{highlight.competitor}</span>
</div>
@@ -54,4 +54,3 @@ export function HighlightsGrid({ highlights }: HighlightsGridProps) {
</Section>
);
}

View File

@@ -1,7 +1,6 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareMigration } from '@/lib/compare';
import { CheckIcon, ClockIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Section, SectionHeader } from '@/components/section';
import type { CompareMigration } from '@/lib/compare';
interface MigrationSectionProps {
migration: CompareMigration;
@@ -11,22 +10,22 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
return (
<Section className="container">
<SectionHeader
title={migration.title}
description={migration.intro}
title={migration.title}
variant="sm"
/>
{/* Difficulty and time */}
<div className="row gap-6 mt-8">
<div className="row mt-8 gap-6">
<div className="col gap-2">
<div className="row gap-2 items-center text-sm text-muted-foreground">
<div className="row items-center gap-2 text-muted-foreground text-sm">
<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">
<div className="row items-center gap-2 text-muted-foreground text-sm">
<ClockIcon className="size-4" />
<span className="font-medium">Estimated time:</span>
<span>{migration.estimated_time}</span>
@@ -35,16 +34,18 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
</div>
{/* Steps */}
<div className="col gap-4 mt-12">
<div className="col mt-12 gap-4">
{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">
<div className="col gap-2 rounded-2xl border p-6" key={step.title}>
<div className="row items-start gap-3">
<div className="center-center size-8 shrink-0 rounded-full bg-primary/10 font-semibold text-sm">
{index + 1}
</div>
<div className="col gap-1 flex-1">
<div className="col flex-1 gap-1">
<h3 className="font-semibold">{step.title}</h3>
<p className="text-sm text-muted-foreground">{step.description}</p>
<p className="text-muted-foreground text-sm">
{step.description}
</p>
</div>
</div>
</div>
@@ -52,20 +53,22 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
</div>
{/* SDK Compatibility */}
<div className="mt-12 p-6 border rounded-2xl bg-muted/30">
<div className="mt-12 rounded-2xl border bg-muted/30 p-6">
<div className="col gap-4">
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
<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>
<p className="text-muted-foreground text-sm">
{migration.sdk_compatibility.notes}
</p>
</div>
</div>
{/* Historical Data */}
<div className="mt-6 p-6 border rounded-2xl bg-muted/30">
<div className="mt-6 rounded-2xl border bg-muted/30 p-6">
<div className="col gap-4">
<div className="row gap-2 items-center">
<div className="row items-center gap-2">
{migration.historical_data.can_import ? (
<CheckIcon className="size-5 text-green-500" />
) : (
@@ -73,10 +76,11 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
)}
<h3 className="font-semibold">Historical Data Import</h3>
</div>
<p className="text-sm text-muted-foreground">{migration.historical_data.notes}</p>
<p className="text-muted-foreground text-sm">
{migration.historical_data.notes}
</p>
</div>
</div>
</Section>
);
}

View File

@@ -1,8 +1,8 @@
import { DollarSignIcon } from 'lucide-react';
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;
@@ -24,34 +24,34 @@ export function PricingComparison({
return (
<Section className="container">
<SectionHeader
title={pricing.title}
description={pricing.intro}
align="center"
description={pricing.intro}
title={pricing.title}
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<div className="mt-12 grid gap-6 md:grid-cols-2">
<FeatureCard
title="OpenPanel"
className="border-green-500/20 bg-green-500/5"
description={pricing.openpanel.model}
icon={DollarSignIcon}
className="border-green-500/20 bg-green-500/5"
title="OpenPanel"
>
<div className="col gap-3 mt-4">
<p className="text-sm text-muted-foreground">
<div className="col mt-4 gap-3">
<p className="text-muted-foreground text-sm">
{pricing.openpanel.description}
</p>
</div>
</FeatureCard>
<FeatureCard
title={competitorName}
description={pricing.competitor.model}
icon={DollarSignIcon}
title={competitorName}
>
<div className="col gap-3 mt-4">
<p className="text-sm text-muted-foreground">
<div className="col mt-4 gap-3">
<p className="text-muted-foreground text-sm">
{pricing.competitor.description}
</p>
{pricing.competitor.free_tier && (
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
Free tier: {pricing.competitor.free_tier}
</p>
)}
@@ -59,21 +59,21 @@ export function PricingComparison({
</FeatureCard>
</div>
{pricingTable.length > 0 && (
<div className="mt-12 border rounded-3xl overflow-hidden">
<div className="mt-12 overflow-hidden rounded-3xl border">
<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',
'grid gap-4 p-6 md:grid-cols-3',
index % 2 === 0 ? 'bg-muted/30' : 'bg-background'
)}
key={row.feature}
>
<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">
<div className="text-muted-foreground text-sm">
{row.competitor}
</div>
</div>

View File

@@ -1,10 +1,10 @@
'use client';
import { motion } from 'framer-motion';
import { ArrowRightIcon, CheckIcon } from 'lucide-react';
import Link from 'next/link';
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;
@@ -47,71 +47,69 @@ export function PricingSection({
return (
<Section className="container">
<SectionHeader
title={pricing.title}
description={pricing.intro}
title={pricing.title}
variant="sm"
/>
{/* Pricing comparison */}
<motion.div
className="mt-12 grid gap-6 md:grid-cols-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-100px' }}
variants={containerVariants}
className="grid md:grid-cols-2 gap-6 mt-12"
viewport={{ once: true, margin: '-100px' }}
whileInView="visible"
>
{/* OpenPanel Card */}
<motion.div
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-emerald-500/30"
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="pointer-events-none absolute inset-0 bg-linear-to-br light:from-emerald-800/10 light:via-transparent light:to-green-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5" />
<div className="col relative z-10 gap-3">
<div className="col gap-2">
<h3 className="text-xl font-semibold">OpenPanel</h3>
<p className="text-sm text-muted-foreground font-medium">
<h3 className="font-semibold text-xl">OpenPanel</h3>
<p className="font-medium text-muted-foreground text-sm">
{pricing.openpanel.model}
</p>
</div>
<div className="col gap-2 mt-2">
<div className="col mt-2 gap-2">
{openpanelPoints.map((point, index) => (
<motion.div
key={index}
className="row group/item items-start gap-2"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
key={index}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
viewport={{ once: true }}
whileInView={{ opacity: 1, x: 0 }}
>
<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">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-emerald-600 transition-transform duration-300 group-hover/item:scale-110 dark:text-emerald-400" />
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
{point.trim()}
</p>
</motion.div>
))}
</div>
<motion.div
className="col mt-2 gap-2"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="col gap-2 mt-2"
viewport={{ once: true }}
whileInView={{ opacity: 1 }}
>
<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">
<div className="row items-center gap-2 rounded-lg border border-emerald-500/10 bg-muted/30 p-3">
<span className="font-medium text-muted-foreground text-xs">
Free tier:
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
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">
<div className="row items-center gap-2 rounded-lg border border-emerald-500/10 bg-muted/30 p-3">
<span className="font-medium text-muted-foreground text-xs">
Free trial:
</span>
<span className="text-xs text-muted-foreground">
30 days
</span>
<span className="text-muted-foreground text-xs">30 days</span>
</div>
</motion.div>
</div>
@@ -119,29 +117,29 @@ export function PricingSection({
{/* Competitor Card */}
<motion.div
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-orange-500/30"
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="pointer-events-none absolute inset-0 bg-linear-to-br light:from-orange-800/10 light:via-transparent light:to-amber-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5" />
<div className="col relative z-10 gap-3">
<div className="col gap-2">
<h3 className="text-xl font-semibold">{competitorName}</h3>
<p className="text-sm text-muted-foreground font-medium">
<h3 className="font-semibold text-xl">{competitorName}</h3>
<p className="font-medium text-muted-foreground text-sm">
{pricing.competitor.model}
</p>
</div>
<div className="col gap-2 mt-2">
<div className="col mt-2 gap-2">
{competitorPoints.map((point, index) => (
<motion.div
key={index}
className="row group/item items-start gap-2"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
key={index}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
viewport={{ once: true }}
whileInView={{ opacity: 1, x: 0 }}
>
<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">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-orange-600 transition-transform duration-300 group-hover/item:scale-110 dark:text-orange-400" />
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
{point.trim()}
</p>
</motion.div>
@@ -149,16 +147,16 @@ export function PricingSection({
</div>
{pricing.competitor.free_tier && (
<motion.div
className="row mt-2 items-center gap-2 rounded-lg border border-orange-500/10 bg-muted/30 p-3"
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"
viewport={{ once: true }}
whileInView={{ opacity: 1 }}
>
<span className="text-xs font-medium text-muted-foreground">
<span className="font-medium text-muted-foreground text-xs">
Free tier:
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{pricing.competitor.free_tier}
</span>
</motion.div>
@@ -166,18 +164,18 @@ export function PricingSection({
{pricing.competitor.pricing_url && (
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.4 }}
viewport={{ once: true }}
whileInView={{ opacity: 1 }}
>
<Link
className="row group/link mt-2 items-center gap-2 text-primary text-xs transition-colors duration-300 hover:text-primary/80"
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"
target="_blank"
>
<span>View pricing</span>
<ArrowRightIcon className="size-3 group-hover/link:translate-x-1 transition-transform duration-300" />
<ArrowRightIcon className="size-3 transition-transform duration-300 group-hover/link:translate-x-1" />
</Link>
</motion.div>
)}
@@ -187,4 +185,3 @@ export function PricingSection({
</Section>
);
}

View File

@@ -1,6 +1,11 @@
import {
PuzzleIcon,
ShieldIcon,
TrendingUpIcon,
UsersIcon,
} from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import { CompareSummary } from '@/lib/compare';
import { UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon } from 'lucide-react';
import type { CompareSummary } from '@/lib/compare';
interface ProblemSectionProps {
summary: CompareSummary;
@@ -9,25 +14,28 @@ interface ProblemSectionProps {
const problemIcons = [UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon];
export function ProblemSection({ summary, competitorName }: ProblemSectionProps) {
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}
title={summary.title}
variant="sm"
/>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{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">
<div className="col gap-3 text-center" key={problem}>
<div className="center-center mx-auto size-12 rounded-full bg-muted">
<Icon className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{problem}</p>
<p className="text-muted-foreground text-sm">{problem}</p>
</div>
);
})}
@@ -35,4 +43,3 @@ export function ProblemSection({ summary, competitorName }: ProblemSectionProps)
</Section>
);
}

View File

@@ -9,8 +9,12 @@ interface RelatedLinksProps {
export function RelatedLinksSection({ relatedLinks }: RelatedLinksProps) {
if (
!relatedLinks ||
(!relatedLinks.guides?.length && !relatedLinks.articles?.length && !relatedLinks.alternatives?.length)
!(
relatedLinks &&
(relatedLinks.guides?.length ||
relatedLinks.articles?.length ||
relatedLinks.alternatives?.length)
)
) {
return null;
}

View File

@@ -1,45 +1,48 @@
import { CheckIcon, XIcon } from 'lucide-react';
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { CompareSummary } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import type { CompareSummary } from '@/lib/compare';
interface SummaryComparisonProps {
summary: CompareSummary;
competitorName: string;
}
export function SummaryComparison({ summary, competitorName }: SummaryComparisonProps) {
export function SummaryComparison({
summary,
competitorName,
}: SummaryComparisonProps) {
return (
<Section className="container">
<SectionHeader
title="Quick comparison"
description={summary.one_liner}
align="center"
description={summary.one_liner}
title="Quick comparison"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<div className="mt-12 grid gap-6 md:grid-cols-2">
<FeatureCard
title="Best for OpenPanel"
description=""
className="border-green-500/20 bg-green-500/5"
description=""
title="Best for OpenPanel"
>
<ul className="col gap-3 mt-4">
<ul className="col mt-4 gap-3">
{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" />
<li className="row items-start gap-2 text-sm" key={item}>
<CheckIcon className="mt-0.5 size-4 shrink-0 text-green-500" />
<span>{item}</span>
</li>
))}
</ul>
</FeatureCard>
<FeatureCard
title={`Best for ${competitorName}`}
description=""
className="border-muted"
description=""
title={`Best for ${competitorName}`}
>
<ul className="col gap-3 mt-4">
<ul className="col mt-4 gap-3">
{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" />
<li className="row items-start gap-2 text-sm" key={item}>
<XIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">{item}</span>
</li>
))}
@@ -49,4 +52,3 @@ export function SummaryComparison({ summary, competitorName }: SummaryComparison
</Section>
);
}

View File

@@ -1,6 +1,5 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareTechnicalComparison } from '@/lib/compare';
import { CheckIcon, XIcon } from 'lucide-react';
import type { CompareTechnicalComparison } from '@/lib/compare';
import { cn } from '@/lib/utils';
interface TechnicalComparisonProps {
@@ -13,7 +12,7 @@ function renderValue(value: string | string[]) {
return (
<ul className="col gap-1">
{value.map((item, idx) => (
<li key={idx} className="text-sm">
<li className="text-sm" key={idx}>
{item}
</li>
))}
@@ -30,28 +29,30 @@ export function TechnicalComparison({
return (
<Section className="container">
<SectionHeader
title={technical.title}
description={technical.intro}
title={technical.title}
variant="sm"
/>
<div className="mt-12 border rounded-2xl overflow-hidden">
<div className="mt-12 overflow-hidden rounded-2xl border">
<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>
<th className="p-4 text-left font-semibold">Feature</th>
<th className="p-4 text-left font-semibold">OpenPanel</th>
<th className="p-4 text-left 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'
)}
key={item.label}
>
<td className="p-4 font-medium">{item.label}</td>
<td className="p-4">{renderValue(item.openpanel)}</td>
@@ -59,7 +60,7 @@ export function TechnicalComparison({
<div className="col gap-1">
{renderValue(item.competitor)}
{item.notes && (
<span className="text-xs text-muted-foreground/70 mt-1">
<span className="mt-1 text-muted-foreground/70 text-xs">
{item.notes}
</span>
)}
@@ -74,4 +75,3 @@ export function TechnicalComparison({
</Section>
);
}

View File

@@ -1,8 +1,13 @@
import {
CheckIcon,
MapPinIcon,
ServerIcon,
ShieldIcon,
XIcon,
} from 'lucide-react';
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';
import type { CompareTrustCompliance } from '@/lib/compare';
interface TrustComplianceProps {
trust: CompareTrustCompliance;
@@ -12,41 +17,41 @@ export function TrustCompliance({ trust }: TrustComplianceProps) {
return (
<Section className="container">
<SectionHeader
title={trust.title}
description={trust.intro}
title={trust.title}
variant="sm"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<div className="mt-12 grid gap-6 md:grid-cols-2">
<FeatureCard
title="OpenPanel"
description=""
className="border-green-500/20 bg-green-500/5"
description=""
title="OpenPanel"
>
<div className="col gap-4 mt-4">
<div className="col mt-4 gap-4">
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<div className="row items-center gap-2 text-sm">
<ShieldIcon className="size-4" />
<span className="font-medium">Data Processing</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
<p className="ml-6 text-muted-foreground text-sm">
{trust.openpanel.data_processing}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<div className="row items-center gap-2 text-sm">
<MapPinIcon className="size-4" />
<span className="font-medium">Data Location</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
<p className="ml-6 text-muted-foreground text-sm">
{trust.openpanel.data_location}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<div className="row items-center gap-2 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">
<div className="row ml-6 items-center gap-2 text-sm">
{trust.openpanel.self_hosting ? (
<>
<CheckIcon className="size-4 text-green-500" />
@@ -62,32 +67,32 @@ export function TrustCompliance({ trust }: TrustComplianceProps) {
</div>
</div>
</FeatureCard>
<FeatureCard title="Competitor" description="">
<div className="col gap-4 mt-4">
<FeatureCard description="" title="Competitor">
<div className="col mt-4 gap-4">
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<div className="row items-center gap-2 text-sm">
<ShieldIcon className="size-4" />
<span className="font-medium">Data Processing</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
<p className="ml-6 text-muted-foreground text-sm">
{trust.competitor.data_processing}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<div className="row items-center gap-2 text-sm">
<MapPinIcon className="size-4" />
<span className="font-medium">Data Location</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
<p className="ml-6 text-muted-foreground text-sm">
{trust.competitor.data_location}
</p>
</div>
<div className="col gap-2">
<div className="row gap-2 items-center text-sm">
<div className="row items-center gap-2 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">
<div className="row ml-6 items-center gap-2 text-sm">
{trust.competitor.self_hosting ? (
<>
<CheckIcon className="size-4 text-green-500" />
@@ -107,4 +112,3 @@ export function TrustCompliance({ trust }: TrustComplianceProps) {
</Section>
);
}

View File

@@ -1,5 +1,5 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareUseCases } from '@/lib/compare';
import type { CompareUseCases } from '@/lib/compare';
interface UseCasesProps {
useCases: CompareUseCases;
@@ -9,15 +9,17 @@ export function UseCases({ useCases }: UseCasesProps) {
return (
<Section className="container">
<SectionHeader
title={useCases.title}
description={useCases.intro}
title={useCases.title}
variant="sm"
/>
<div className="grid md:grid-cols-2 gap-6 mt-12">
<div className="mt-12 grid gap-6 md:grid-cols-2">
{useCases.items.map((useCase) => (
<div key={useCase.title} className="col gap-2 p-6 border rounded-2xl">
<div className="col gap-2 rounded-2xl border p-6" key={useCase.title}>
<h3 className="font-semibold">{useCase.title}</h3>
<p className="text-sm text-muted-foreground">{useCase.description}</p>
<p className="text-muted-foreground text-sm">
{useCase.description}
</p>
</div>
))}
</div>

View File

@@ -1,8 +1,8 @@
'use client';
import { motion } from 'framer-motion';
import { Section } from '@/components/section';
import type { CompareSummary } from '@/lib/compare';
import { motion } from 'framer-motion';
interface WhoShouldChooseProps {
summary: CompareSummary;
@@ -36,43 +36,43 @@ export function WhoShouldChoose({
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 className="col mb-12 gap-4">
<h2 className="font-semibold text-3xl md:text-4xl">{summary.title}</h2>
<p className="max-w-3xl text-muted-foreground">{summary.intro}</p>
</div>
<motion.div
className="grid gap-6 md:grid-cols-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-100px' }}
variants={containerVariants}
className="grid md:grid-cols-2 gap-6"
viewport={{ once: true, margin: '-100px' }}
whileInView="visible"
>
{/* OpenPanel Card */}
<motion.div
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-emerald-500/30"
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="pointer-events-none absolute inset-0 bg-linear-to-br light:from-emerald-800/10 light:via-transparent light:to-green-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5" />
<div className="col relative z-10 gap-3">
<div className="col gap-2">
<h3 className="text-xl font-semibold">Choose OpenPanel if...</h3>
<h3 className="font-semibold text-xl">Choose OpenPanel if...</h3>
</div>
<div className="col gap-2 mt-2">
<div className="col mt-2 gap-2">
{openpanelItems.map((item, index) => (
<motion.div
key={item}
className="row group/item items-start gap-2"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
key={item}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
viewport={{ once: true }}
whileInView={{ opacity: 1, x: 0 }}
>
<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">
<div className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-emerald-600 transition-transform duration-300 group-hover/item:scale-110 dark:bg-emerald-400">
<span className="font-bold text-[10px] text-white">
{index + 1}
</span>
</div>
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
{item}
</p>
</motion.div>
@@ -83,32 +83,32 @@ export function WhoShouldChoose({
{/* Competitor Card */}
<motion.div
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-orange-500/30"
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="pointer-events-none absolute inset-0 bg-linear-to-br light:from-orange-800/10 light:via-transparent light:to-amber-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5" />
<div className="col relative z-10 gap-3">
<div className="col gap-2">
<h3 className="text-xl font-semibold">
<h3 className="font-semibold text-xl">
Choose {competitorName} if...
</h3>
</div>
<div className="col gap-2 mt-2">
<div className="col mt-2 gap-2">
{competitorItems.map((item, index) => (
<motion.div
key={item}
className="row group/item items-start gap-2"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
key={item}
transition={{ delay: index * 0.1 }}
className="row gap-2 items-start group/item"
viewport={{ once: true }}
whileInView={{ opacity: 1, x: 0 }}
>
<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">
<div className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-orange-600 transition-transform duration-300 group-hover/item:scale-110 dark:bg-orange-400">
<span className="font-bold text-[10px] text-white">
{index + 1}
</span>
</div>
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
{item}
</p>
</motion.div>

View File

@@ -1,15 +1,15 @@
import { Section, SectionHeader } from '@/components/section';
import { CompareSummary } from '@/lib/compare';
import {
UsersIcon,
SparklesIcon,
SearchIcon,
MoonIcon,
ShieldIcon,
ServerIcon,
ZapIcon,
CheckCircleIcon,
MoonIcon,
SearchIcon,
ServerIcon,
ShieldIcon,
SparklesIcon,
UsersIcon,
ZapIcon,
} from 'lucide-react';
import { Section, SectionHeader } from '@/components/section';
import type { CompareSummary } from '@/lib/compare';
interface WhySwitchProps {
summary: CompareSummary;
@@ -32,16 +32,16 @@ export function WhySwitch({ summary }: WhySwitchProps) {
return (
<Section className="container">
<SectionHeader
title={summary.title}
description={summary.intro}
title={summary.title}
variant="sm"
/>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mt-12">
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{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">
<div className="col gap-3" key={benefit}>
<div className="center-center size-10 rounded-lg bg-primary/10">
<Icon className="size-5 text-primary" />
</div>
<h3 className="font-semibold text-sm">{benefit}</h3>
@@ -52,4 +52,3 @@ export function WhySwitch({ summary }: WhySwitchProps) {
</Section>
);
}

View File

@@ -1,8 +1,7 @@
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';
import { FeatureCardContainer } from '@/components/feature-card';
interface CompareCardProps {
name: string;
@@ -20,30 +19,29 @@ export function CompareCard({
return (
<Link href={url}>
<FeatureCardContainer>
<div className="row gap-3 items-center">
<div className="row items-center gap-3">
{logo && (
<div className="relative size-10 shrink-0 rounded-lg overflow-hidden border bg-background p-1.5">
<div className="relative size-10 shrink-0 overflow-hidden rounded-lg border bg-background p-1.5">
<Image
src={logo}
alt={`${name} logo`}
width={40}
className="h-full w-full object-contain"
height={40}
className="object-contain w-full h-full"
src={logo}
width={40}
/>
</div>
)}
<div className="col gap-1 flex-1 min-w-0">
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
<div className="col min-w-0 flex-1 gap-1">
<h3 className="font-semibold text-lg transition-colors group-hover:text-primary">
{name}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
<p className="line-clamp-2 text-muted-foreground text-sm">
{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" />
<ArrowRightIcon className="size-5 shrink-0 text-muted-foreground opacity-0 transition-all duration-300 group-hover:translate-x-1 group-hover:text-primary group-hover:opacity-100" />
</div>
</FeatureCardContainer>
</Link>
);
}

View File

@@ -1,12 +1,12 @@
import type { Metadata } from 'next';
import { CompareCard } from './_components/compare-card';
import { CompareHero } from './[slug]/_components/compare-hero';
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 =
@@ -28,7 +28,7 @@ const heroData = {
export default async function CompareIndexPage() {
const comparisons = compareSource.sort((a, b) =>
a.competitor.name.localeCompare(b.competitor.name),
a.competitor.name.localeCompare(b.competitor.name)
);
return (
@@ -37,36 +37,36 @@ export default async function CompareIndexPage() {
<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."
srcDark="/screenshots/overview-dark.png"
srcLight="/screenshots/overview-light.png"
/>
</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."
title="All product comparisons"
variant="sm"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-12">
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{comparisons.map((comparison) => (
<CompareCard
key={comparison.slug}
url={comparison.url}
name={comparison.competitor.name}
description={comparison.competitor.short_description}
key={comparison.slug}
name={comparison.competitor.name}
url={comparison.url}
/>
))}
</div>
</Section>
<CtaBanner
title="Ready to get started?"
description="Join thousands of teams using OpenPanel for their analytics needs."
ctaText="Get Started Free"
ctaLink="https://dashboard.openpanel.dev/onboarding"
ctaText="Get Started Free"
description="Join thousands of teams using OpenPanel for their analytics needs."
title="Ready to get started?"
/>
</div>
);

View File

@@ -1,7 +1,7 @@
import { ZapIcon } from 'lucide-react';
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;
@@ -9,22 +9,26 @@ interface CapabilitiesProps {
capabilities: FeatureCapability[];
}
export function Capabilities({ title, intro, capabilities }: CapabilitiesProps) {
export function Capabilities({
title,
intro,
capabilities,
}: CapabilitiesProps) {
return (
<Section className="container">
<SectionHeader
title={title}
description={intro}
variant="sm"
className="mb-12"
description={intro}
title={title}
variant="sm"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{capabilities.map((cap) => (
<FeatureCard
key={cap.title}
title={cap.title}
description={cap.description}
icon={ZapIcon}
key={cap.title}
title={cap.title}
/>
))}
</div>

View File

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

View File

@@ -9,19 +9,21 @@ export function FeatureUseCasesSection({ useCases }: FeatureUseCasesProps) {
return (
<Section className="container">
<SectionHeader
title={useCases.title}
description={useCases.intro}
variant="sm"
className="mb-12"
description={useCases.intro}
title={useCases.title}
variant="sm"
/>
<div className="grid md:grid-cols-2 gap-6">
<div className="grid gap-6 md:grid-cols-2">
{useCases.items.map((useCase) => (
<div
className="col gap-2 rounded-2xl border bg-card/50 p-6"
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>
<p className="text-muted-foreground text-sm">
{useCase.description}
</p>
</div>
))}
</div>

View File

@@ -10,27 +10,29 @@ export function HowItWorks({ data }: HowItWorksProps) {
return (
<Section className="container">
<SectionHeader
title={data.title}
description={data.intro}
variant="sm"
className="mb-12"
description={data.intro}
title={data.title}
variant="sm"
/>
<div className="relative">
{data.steps.map((step, index) => (
<div
className="relative mb-8 flex min-w-0 gap-4 last:mb-0"
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">
<div className="flex shrink-0 flex-col items-center">
<div className="flex size-10 items-center justify-center rounded-full bg-primary font-semibold text-primary-foreground 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 className="mt-2 min-h-[2rem] w-0.5 flex-1 bg-border" />
)}
</div>
<div className="flex-1 pt-1 min-w-0 pb-8">
<h3 className="font-semibold text-foreground mb-1">{step.title}</h3>
<div className="min-w-0 flex-1 pt-1 pb-8">
<h3 className="mb-1 font-semibold text-foreground">
{step.title}
</h3>
<p className={cn('text-muted-foreground text-sm')}>
{step.description}
</p>

View File

@@ -1,8 +1,8 @@
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
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;
@@ -13,32 +13,34 @@ export function RelatedFeatures({
title = 'Related features',
related,
}: RelatedFeaturesProps) {
if (related.length === 0) return null;
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"
description="Explore more capabilities that work together with this feature."
title={title}
variant="sm"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{related.map((item) => (
<Link key={item.slug} href={`/features/${item.slug}`}>
<Link href={`/features/${item.slug}`} key={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">
<div className="row items-center gap-3">
<div className="col min-w-0 flex-1 gap-1">
<h3 className="font-semibold text-lg transition-colors group-hover:text-primary">
{item.title}
</h3>
{item.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
<p className="line-clamp-2 text-muted-foreground text-sm">
{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" />
<ArrowRightIcon className="size-5 shrink-0 text-muted-foreground opacity-0 transition-all duration-300 group-hover:translate-x-1 group-hover:text-primary group-hover:opacity-100" />
</div>
</FeatureCardContainer>
</Link>

View File

@@ -1,7 +1,7 @@
import Markdown from 'react-markdown';
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;
@@ -12,7 +12,7 @@ export function WhatItIs({ definition, className }: WhatItIsProps) {
return (
<Section className={cn('container', className)}>
{definition.title && (
<SectionHeader title={definition.title} variant="sm" className="mb-8" />
<SectionHeader className="mb-8" title={definition.title} variant="sm" />
)}
<div className="prose prose-lg max-w-3xl text-muted-foreground [&_strong]:text-foreground">
<Markdown>{definition.text}</Markdown>

View File

@@ -1,12 +1,3 @@
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';
@@ -17,6 +8,11 @@ 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';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { WindowImage } from '@/components/window-image';
import { getAllFeatureSlugs, getFeatureData } from '@/lib/features';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
export async function generateStaticParams() {
const slugs = await getAllFeatureSlugs();
@@ -82,10 +78,10 @@ export default async function FeaturePage({
return (
<div>
<Script
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
id="feature-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<FeatureHero hero={data.hero} />
@@ -98,9 +94,9 @@ export default async function FeaturePage({
<WhatItIs definition={data.definition} />
<Capabilities
title={capabilitiesSection.title}
intro={capabilitiesSection.intro}
capabilities={data.capabilities}
intro={capabilitiesSection.intro}
title={capabilitiesSection.title}
/>
{data.screenshots[1] && (
@@ -132,10 +128,10 @@ export default async function FeaturePage({
</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}
ctaText={data.cta.label}
description="Track events in minutes. Free 30-day trial, no credit card required."
title="Ready to get started?"
/>
</div>
);

View File

@@ -1,3 +1,9 @@
import { ArrowLeftIcon, ClockIcon } 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';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
@@ -7,16 +13,10 @@ import { GuideCard } from '@/components/guide-card';
import { Logo } from '@/components/logo';
import { SectionHeader } from '@/components/section';
import { Toc } from '@/components/toc';
import { url, getAuthor } from '@/lib/layout.shared';
import { getAuthor, url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { guideSource } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { ArrowLeftIcon, ClockIcon } 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';
const difficultyColors = {
beginner: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
@@ -77,7 +77,7 @@ export default async function Page({
.filter(
(item) =>
item.data.difficulty === guide?.data.difficulty &&
item.url !== guide?.url,
item.url !== guide?.url
)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.slice(0, 3);
@@ -108,34 +108,34 @@ export default async function Page({
<HeroContainer>
<div className="col">
<Link
className="mb-4 flex items-center gap-2 text-muted-foreground"
href={goBackUrl}
className="flex items-center gap-2 mb-4 text-muted-foreground"
>
<ArrowLeftIcon className="w-4 h-4" />
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to all guides</span>
</Link>
<SectionHeader
as="h1"
title={guide?.data.title}
description={guide?.data.description}
title={guide?.data.title}
/>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
<div className="row mt-8 items-center gap-4">
<div className="center-center size-10 rounded-full bg-black">
{author?.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
alt={author.name}
width={48}
className="size-10 rounded-full object-cover"
height={48}
src={author.image}
width={48}
/>
) : (
<Logo className="w-6 h-6 fill-white" />
<Logo className="h-6 w-6 fill-white" />
)}
</div>
<div className="col flex-1">
<p className="font-medium">{author?.name || 'OpenPanel Team'}</p>
<div className="row gap-4 items-center">
<div className="row items-center gap-4">
<p className="text-muted-foreground text-sm">
{guide?.data.date.toLocaleDateString()}
</p>
@@ -146,14 +146,14 @@ export default async function Page({
)}
</div>
</div>
<div className="row gap-3 items-center">
<div className="row items-center gap-3">
<span
className={`font-mono text-xs px-3 py-1 rounded ${difficultyColors[guide?.data.difficulty || 'beginner']}`}
className={`rounded px-3 py-1 font-mono text-xs ${difficultyColors[guide?.data.difficulty || 'beginner']}`}
>
{difficultyLabels[guide?.data.difficulty || 'beginner']}
</span>
<div className="row gap-1 items-center text-muted-foreground text-sm">
<ClockIcon className="w-4 h-4" />
<div className="row items-center gap-1 text-muted-foreground text-sm">
<ClockIcon className="h-4 w-4" />
<span>{guide?.data.timeToComplete} min</span>
</div>
</div>
@@ -161,23 +161,23 @@ export default async function Page({
</div>
</HeroContainer>
<Script
strategy="beforeInteractive"
id="guide-howto-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
id="guide-howto-schema"
strategy="beforeInteractive"
type="application/ld+json"
/>
<article className="container max-w-5xl col">
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
<article className="col container max-w-5xl">
<div className="grid grid-cols-1 gap-0 md:grid-cols-[1fr_300px]">
<div className="min-w-0">
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
<div className="prose [&_img]:h-auto [&_img]:max-w-full [&_table]:w-auto">
<Body components={getMDXComponents()} />
</div>
</div>
<aside className="pl-12 pb-12 gap-8 col">
<aside className="col gap-8 pb-12 pl-12">
<Toc toc={guide?.data.toc} />
<FeatureCardContainer className="gap-2">
<span className="text-lg font-semibold">Try OpenPanel</span>
<p className="text-muted-foreground text-sm mb-4">
<span className="font-semibold text-lg">Try OpenPanel</span>
<p className="mb-4 text-muted-foreground text-sm">
Give it a spin for free. No credit card required.
</p>
<GetStartedButton />
@@ -187,18 +187,18 @@ export default async function Page({
{relatedGuides.length > 0 && (
<div className="my-16">
<h3 className="text-2xl font-bold mb-8">Related guides</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<h3 className="mb-8 font-bold text-2xl">Related guides</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{relatedGuides.map((item) => (
<GuideCard
key={item.url}
url={item.url}
title={item.data.title}
difficulty={item.data.difficulty}
timeToComplete={item.data.timeToComplete}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
difficulty={item.data.difficulty}
key={item.url}
team={item.data.team}
timeToComplete={item.data.timeToComplete}
title={item.data.title}
url={item.url}
/>
))}
</div>

View File

@@ -1,3 +1,5 @@
import type { Metadata } from 'next';
import Script from 'next/script';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
@@ -6,8 +8,6 @@ import { Section, SectionHeader } from '@/components/section';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { guideSource } from '@/lib/source';
import type { Metadata } from 'next';
import Script from 'next/script';
export const metadata: Metadata = getPageMetadata({
title: 'Implementation Guides',
@@ -19,7 +19,7 @@ export const metadata: Metadata = getPageMetadata({
export default async function Page() {
const guides = (await guideSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
(a, b) => b.data.date.getTime() - a.data.date.getTime()
);
// Create ItemList schema for SEO
@@ -43,32 +43,32 @@ export default async function Page() {
<div>
<HeroContainer className="-mb-32">
<SectionHeader
as="h1"
align="center"
as="h1"
className="flex-1"
title="Implementation Guides"
description="Step-by-step tutorials for adding privacy-first analytics to your app with OpenPanel."
title="Implementation Guides"
/>
</HeroContainer>
<Script
strategy="beforeInteractive"
id="guides-itemlist-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListSchema) }}
id="guides-itemlist-schema"
strategy="beforeInteractive"
type="application/ld+json"
/>
<Section className="container grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
<Section className="container grid grid-cols-1 gap-8 sm:grid-cols-2 md:grid-cols-3">
{guides.map((item) => (
<GuideCard
key={item.url}
url={item.url}
title={item.data.title}
difficulty={item.data.difficulty}
timeToComplete={item.data.timeToComplete}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
difficulty={item.data.difficulty}
key={item.url}
team={item.data.team}
timeToComplete={item.data.timeToComplete}
title={item.data.title}
url={item.url}
/>
))}
</Section>

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { Footer } from '@/components/footer';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
export default function Layout({
children,

View File

@@ -1,11 +1,3 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { FaqItem, Faqs } from '@/components/faq';
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 {
BarChartIcon,
CheckIcon,
@@ -22,6 +14,14 @@ import {
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { FaqItem, Faqs } from '@/components/faq';
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';
export const metadata: Metadata = getPageMetadata({
title: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
@@ -60,17 +60,18 @@ export default function OpenSourcePage() {
return (
<div>
<Script
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
id="open-source-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"
as="h1"
className="flex-1"
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
title={
<>
Free Analytics for
@@ -78,16 +79,15 @@ export default function OpenSourcePage() {
Open Source Projects
</>
}
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
/>
<div className="col gap-4 justify-center items-center mt-8">
<Button size="lg" asChild>
<div className="col mt-8 items-center justify-center gap-4">
<Button asChild size="lg">
<Link href="mailto:oss@openpanel.dev">
Apply for Free Access
<MailIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Up to 2.5M events/month No credit card required
</p>
</div>
@@ -99,29 +99,29 @@ export default function OpenSourcePage() {
{/* What You Get Section */}
<Section className="my-0">
<SectionHeader
title="What you get"
description="Everything you need to understand your users and grow your open source project."
title="What you get"
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<div className="mt-8 grid gap-6 md:grid-cols-2">
<FeatureCard
title="2.5 Million Events/Month"
description="More than enough for most open source projects. Track page views, user actions, and custom events without worrying about limits."
icon={BarChartIcon}
title="2.5 Million Events/Month"
/>
<FeatureCard
title="Full Feature Access"
description="Same powerful capabilities as paid plans. Funnels, retention analysis, custom dashboards, and real-time analytics."
icon={ZapIcon}
title="Full Feature Access"
/>
<FeatureCard
title="Unlimited Team Members"
description="Invite your entire contributor team. Collaborate with maintainers and core contributors on understanding your project's growth."
icon={UsersIcon}
title="Unlimited Team Members"
/>
<FeatureCard
title="Priority Support"
description="Dedicated help for open source maintainers. Get faster responses and priority assistance when you need it."
icon={MessageSquareIcon}
title="Priority Support"
/>
</div>
</Section>
@@ -129,10 +129,10 @@ export default function OpenSourcePage() {
{/* Why We Do This Section */}
<Section className="my-0">
<SectionHeader
title="Why we do this"
description="OpenPanel is built by and for the open source community. We believe in giving back."
title="Why we do this"
/>
<div className="col gap-6 mt-8">
<div className="col mt-8 gap-6">
<p className="text-muted-foreground">
We started OpenPanel because we believed analytics tools
shouldn't be complicated or locked behind expensive enterprise
@@ -140,21 +140,21 @@ export default function OpenSourcePage() {
understand the challenges of building and growing a project
without the resources of big corporations.
</p>
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-4 md:grid-cols-3">
<FeatureCard
title="Built for OSS"
description="OpenPanel is open source. We know what it's like to build in the open."
icon={CodeIcon}
title="Built for OSS"
/>
<FeatureCard
title="No Barriers"
description="Analytics shouldn't be a barrier to understanding your users. We're removing that barrier."
icon={HeartHandshakeIcon}
title="No Barriers"
/>
<FeatureCard
title="Giving Back"
description="We're giving back to the projects that inspire us and the community that supports us."
icon={SparklesIcon}
title="Giving Back"
/>
</div>
</div>
@@ -163,21 +163,21 @@ export default function OpenSourcePage() {
{/* What We Ask In Return Section */}
<Section className="my-0">
<SectionHeader
title="What we ask in return"
description="We keep it simple. Just a small way to help us grow and support more projects."
title="What we ask in return"
/>
<div className="row gap-6 mt-8">
<div className="row mt-8 gap-6">
<div className="col gap-6">
<FeatureCard
title="Backlink to OpenPanel"
description="A simple link on your website or README helps others discover OpenPanel. It's a win-win for the community."
icon={LinkIcon}
title="Backlink to OpenPanel"
>
<p className="text-sm text-muted-foreground mt-2">
<p className="mt-2 text-muted-foreground text-sm">
Example: "Analytics powered by{' '}
<Link
href="https://openpanel.dev"
className="text-primary hover:underline"
href="https://openpanel.dev"
>
OpenPanel
</Link>
@@ -185,9 +185,9 @@ export default function OpenSourcePage() {
</p>
</FeatureCard>
<FeatureCard
title="Display a Widget"
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
icon={GlobeIcon}
title="Display a Widget"
>
<a
href="https://openpanel.dev"
@@ -200,15 +200,15 @@ export default function OpenSourcePage() {
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
height="48"
width="100%"
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
width="100%"
/>
</a>
</FeatureCard>
@@ -218,13 +218,13 @@ export default function OpenSourcePage() {
</p>
</div>
<div>
<div className="text-center text-xs text-muted-foreground">
<div className="text-center text-muted-foreground text-xs">
<iframe
title="Realtime Widget"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
width="300"
className="mb-2 rounded-xl border"
height="400"
className="rounded-xl border mb-2"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
title="Realtime Widget"
width="300"
/>
Analytics from{' '}
<a className="underline" href="https://openpanel.dev">
@@ -238,48 +238,48 @@ export default function OpenSourcePage() {
{/* Eligibility Criteria Section */}
<Section className="my-0">
<SectionHeader
title="Eligibility criteria"
description="We want to support legitimate open source projects that are making a difference."
title="Eligibility criteria"
/>
<div className="col gap-4 mt-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="col mt-8 gap-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<CheckIcon className="mt-0.5 size-5 shrink-0 text-primary" />
<div>
<h3 className="font-semibold mb-1">OSI-Approved License</h3>
<p className="text-sm text-muted-foreground">
<h3 className="mb-1 font-semibold">OSI-Approved License</h3>
<p className="text-muted-foreground text-sm">
Your project must use an OSI-approved open source license
(MIT, Apache, GPL, etc.)
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<CheckIcon className="mt-0.5 size-5 shrink-0 text-primary" />
<div>
<h3 className="font-semibold mb-1">Public Repository</h3>
<p className="text-sm text-muted-foreground">
<h3 className="mb-1 font-semibold">Public Repository</h3>
<p className="text-muted-foreground text-sm">
Your code must be publicly available on GitHub, GitLab, or
similar platforms
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<CheckIcon className="mt-0.5 size-5 shrink-0 text-primary" />
<div>
<h3 className="font-semibold mb-1">Active Development</h3>
<p className="text-sm text-muted-foreground">
<h3 className="mb-1 font-semibold">Active Development</h3>
<p className="text-muted-foreground text-sm">
Show evidence of active development and a growing
community
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<CheckIcon className="mt-0.5 size-5 shrink-0 text-primary" />
<div>
<h3 className="font-semibold mb-1">
<h3 className="mb-1 font-semibold">
Non-Commercial Primary Purpose
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
The primary purpose should be non-commercial, though
commercial OSS projects may be considered
</p>
@@ -292,21 +292,21 @@ export default function OpenSourcePage() {
{/* How to Apply Section */}
<Section className="my-0">
<SectionHeader
title="How to apply"
description="Getting started is simple. Just send us an email with a few details about your project."
title="How to apply"
/>
<div className="col gap-6 mt-8">
<div className="grid md:grid-cols-3 gap-6">
<div className="col mt-8 gap-6">
<div className="grid gap-6 md:grid-cols-3">
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
<div className="center-center size-10 rounded-full bg-primary/10 font-semibold text-primary">
1
</div>
<h3 className="font-semibold">Send us an email</h3>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Reach out to{' '}
<Link
href="mailto:oss@openpanel.dev"
className="text-primary hover:underline"
href="mailto:oss@openpanel.dev"
>
oss@openpanel.dev
</Link>{' '}
@@ -314,28 +314,28 @@ export default function OpenSourcePage() {
</p>
</div>
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
<div className="center-center size-10 rounded-full bg-primary/10 font-semibold text-primary">
2
</div>
<h3 className="font-semibold">Include project info</h3>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Share your project URL, license type, and a brief
description of what you're building
</p>
</div>
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
<div className="center-center size-10 rounded-full bg-primary/10 font-semibold text-primary">
3
</div>
<h3 className="font-semibold">We'll review</h3>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
We'll evaluate your project and respond within a few
business days
</p>
</div>
</div>
<div className="mt-4">
<Button size="lg" asChild>
<Button asChild size="lg">
<Link href="mailto:oss@openpanel.dev?subject=Open Source Program Application">
Apply Now
<MailIcon className="size-4" />
@@ -348,8 +348,8 @@ export default function OpenSourcePage() {
{/* FAQ Section */}
<Section className="my-0">
<SectionHeader
title="Frequently asked questions"
description="Everything you need to know about our open source program."
title="Frequently asked questions"
/>
<div className="mt-8">
<Faqs>
@@ -396,10 +396,10 @@ export default function OpenSourcePage() {
</Section>
<CtaBanner
title="Ready to get free analytics for your open source project?"
description="Join other open source projects using OpenPanel to understand their users and grow their communities. Apply today and get started in minutes."
ctaText="Apply for Free Access"
ctaLink="mailto:oss@openpanel.dev?subject=Open Source Program Application"
ctaText="Apply for Free Access"
description="Join other open source projects using OpenPanel to understand their users and grow their communities. Apply today and get started in minutes."
title="Ready to get free analytics for your open source project?"
/>
</div>
</div>

View File

@@ -1,3 +1,8 @@
import { PRICING } from '@openpanel/payments/prices';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
import { CompareCard } from '../compare/_components/compare-card';
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { Faq } from '@/app/(home)/_sections/faq';
import { HeroContainer } from '@/app/(home)/_sections/hero';
@@ -9,11 +14,6 @@ import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { compareSource } from '@/lib/source';
import { formatEventsCount } from '@/lib/utils';
import { PRICING } from '@openpanel/payments/prices';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
import { CompareCard } from '../compare/_components/compare-card';
const title = 'OpenPanel Cloud Pricing';
const description =
@@ -30,7 +30,7 @@ const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description: description,
description,
url: url('/pricing'),
publisher: {
'@type': 'Organization',
@@ -46,18 +46,18 @@ export default function SupporterPage() {
return (
<div>
<Script
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
id="pricing-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer className="-mb-32">
<SectionHeader
as="h1"
align="center"
as="h1"
className="flex-1"
title={title}
description={description}
title={title}
/>
</HeroContainer>
<Pricing />
@@ -74,8 +74,8 @@ 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."
title="Full pricing table"
/>
<div className="prose mt-8">
<table className="bg-card">
@@ -117,40 +117,40 @@ function ComparisonSection() {
const comparisons = compareSource
.filter((item) =>
['plausible', 'mixpanel', 'google', 'posthog', 'matomo', 'umami'].some(
(name) => item.competitor.name.toLowerCase().includes(name),
),
(name) => item.competitor.name.toLowerCase().includes(name)
)
)
.sort((a, b) => a.competitor.name.localeCompare(b.competitor.name));
return (
<Section className="container">
<SectionHeader
title="How do we compare?"
description={
<>
See how OpenPanel stacks up against other analytics tools in our{' '}
<Link
className="underline transition-colors hover:text-primary"
href="/articles/open-source-web-analytics"
className="underline hover:text-primary transition-colors"
>
comprehensive comparison of open source web analytics tools
</Link>
.
</>
}
title="How do we compare?"
/>
<Button asChild className="mt-8 self-start">
<Link href="/compare">View all comparisons</Link>
</Button>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-12">
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{comparisons.map((comparison) => (
<CompareCard
key={comparison.slug}
url={comparison.url}
name={`OpenPanel vs ${comparison.competitor.name}`}
description={comparison.competitor.short_description}
key={comparison.slug}
name={`OpenPanel vs ${comparison.competitor.name}`}
url={comparison.url}
/>
))}
</div>

View File

@@ -1,10 +1,3 @@
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,
@@ -19,6 +12,13 @@ import {
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
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';
export const metadata: Metadata = getPageMetadata({
title: 'Become a Supporter',
@@ -49,17 +49,18 @@ export default function SupporterPage() {
return (
<div>
<Script
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
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"
as="h1"
className="flex-1"
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."
title={
<>
Help us build
@@ -67,16 +68,15 @@ export default function SupporterPage() {
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>
<div className="col mt-8 items-center justify-center gap-4">
<Button asChild size="lg">
<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">
<p className="text-muted-foreground text-sm">
Starting at $20/month Cancel anytime
</p>
</div>
@@ -85,34 +85,34 @@ export default function SupporterPage() {
<div className="container">
{/* Main Content with Sidebar */}
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
<div className="mb-16 grid gap-8 lg:grid-cols-[1fr_380px]">
{/* 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."
title="Why your support matters"
/>
<div className="col gap-6 mt-8">
<div className="col mt-8 gap-6">
<p className="text-muted-foreground">
When you become a supporter, you're directly funding:
</p>
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-4 md:grid-cols-2">
<FeatureCard
title="Active Development"
description="More time fixing bugs, adding features, and improving documentation"
icon={ZapIcon}
title="Active Development"
/>
<FeatureCard
title="Infrastructure"
description="Keeping servers running, CI/CD pipelines, and development tools"
icon={ZapIcon}
title="Infrastructure"
/>
<FeatureCard
title="Independence"
description="Staying focused on what matters: building a tool developers actually want"
icon={ZapIcon}
title="Independence"
/>
</div>
<p className="text-muted-foreground">
@@ -127,36 +127,36 @@ export default function SupporterPage() {
{/* 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."
title="What you get as a supporter"
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<div className="mt-8 grid gap-6 md:grid-cols-2">
<FeatureCard
title="Latest Docker Images"
description="Get bleeding-edge builds on every commit. Access new features weeks before public release."
icon={RocketIcon}
title="Latest Docker Images"
>
<Link
className="mt-2 text-primary text-sm hover:underline"
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}
title="Prioritized Support"
/>
<FeatureCard
title="Feature Requests"
description="Your ideas and feature requests get prioritized in our roadmap. Shape the future of OpenPanel."
icon={SparklesIcon}
title="Feature Requests"
/>
<FeatureCard
title="Exclusive Discord Role"
description="Special badge and recognition in our community. Show your support with pride."
icon={StarIcon}
title="Exclusive Discord Role"
/>
</div>
</Section>
@@ -164,49 +164,49 @@ export default function SupporterPage() {
{/* 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:"
title="Your impact"
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<div className="mt-8 grid gap-6 md:grid-cols-2">
<FeatureCard
title="100% Open Source"
description="Full transparency. Audit the code, contribute, fork it, or self-host without lock-in."
icon={GithubIcon}
title="100% Open Source"
/>
<FeatureCard
title="24/7 Active Development"
description="Continuous improvements and updates. Your support enables faster development cycles."
icon={ClockIcon}
title="24/7 Active Development"
/>
<FeatureCard
title="Self-Hostable"
description="Deploy OpenPanel anywhere - your server, your cloud, or locally. Full flexibility."
icon={InfinityIcon}
title="Self-Hostable"
/>
</div>
</Section>
</div>
{/* Sidebar */}
<aside className="lg:block hidden">
<aside className="hidden lg:block">
<SupporterPerks />
</aside>
</div>
{/* Mobile Perks */}
<div className="lg:hidden mb-16">
<div className="mb-16 lg:hidden">
<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"
ctaText="Become a Supporter"
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."
title="Ready to support OpenPanel?"
/>
</div>
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
<div className="not-prose mt-16 lg:-mx-20 xl:-mx-40">
{/* <Testimonials />
<Faq /> */}
</div>

View File

@@ -1,6 +1,5 @@
'use client';
import { FeatureCardContainer } from '@/components/feature-card';
import { MoreVerticalIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
@@ -14,6 +13,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { FeatureCardContainer } from '@/components/feature-card';
// Sample data for the last 7 days
const data = [
@@ -35,18 +35,18 @@ const CustomTooltip = ({ active, payload, label }: any) => {
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">
<div className="min-w-[200px] rounded-lg border border-border bg-card p-3 shadow-lg">
<div className="mb-2 font-semibold text-sm">{label}</div>
<div className="flex-1 space-y-1 text-muted-foreground text-sm">
<div className="row flex-1 items-center gap-2">
<div className="h-6 w-1 rounded-full bg-foreground" />
<div className="row flex-1 items-center justify-between gap-2 font-medium">
<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">
<div className="row flex-1 items-center gap-2">
<div className="h-6 w-1 rounded-full bg-emerald-500" />
<div className="row flex-1 items-center justify-between gap-2 font-medium">
<span>Revenue</span> <span>${revenue.toLocaleString()}</span>
</div>
</div>
@@ -69,101 +69,101 @@ export function CollaborationChart() {
const totalRevenue = activeData.revenue;
return (
<FeatureCardContainer className="col gap-4 h-full">
<FeatureCardContainer className="col h-full gap-4">
{/* 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>
<p className="text-muted-foreground text-sm">Last 7 days</p>
</div>
<button
className="text-muted-foreground transition-colors hover:text-foreground"
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%">
<div className="min-h-[200px] flex-1">
<ResponsiveContainer height="100%" width="100%">
<ComposedChart
data={data}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
onMouseLeave={() => setActiveIndex(null)}
onMouseMove={(state) => {
if (state?.activeTooltipIndex !== undefined) {
setActiveIndex(state.activeTooltipIndex);
}
}}
onMouseLeave={() => setActiveIndex(null)}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
stroke="hsl(var(--border))"
strokeDasharray="3 3"
/>
<XAxis
axisLine={false}
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
tickLine={false}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
hide
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
tickLine={false}
yAxisId="left"
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
domain={[0, 2400]}
hide
orientation="right"
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
tickLine={false}
yAxisId="right"
/>
<Tooltip content={<CustomTooltip />} cursor={false} />
{/* Revenue bars */}
<Bar yAxisId="right" dataKey="revenue" radius={4}>
<Bar dataKey="revenue" radius={4} yAxisId="right">
{data.map((entry, index) => (
<Cell
key={`cell-${entry.day}`}
className={
activeIndex === index
? 'fill-emerald-500' // Lighter green on hover
: 'fill-foreground/30' // Default green
}
key={`cell-${entry.day}`}
style={{ transition: 'fill 0.2s ease' }}
/>
))}
</Bar>
<Line
yAxisId="left"
type="monotone"
dataKey="visitors"
strokeWidth={2}
stroke="var(--foreground)"
dot={false}
stroke="var(--foreground)"
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 gap-4 center-center">
<div className="center-center grid grid-cols-2 gap-4">
<div>
<div className="text-2xl font-semibold font-mono">
<div className="font-mono font-semibold text-2xl">
{totalVisitors.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Visitors</div>
<div className="text-muted-foreground text-xs">Visitors</div>
</div>
<div>
<div className="text-2xl font-semibold font-mono text-emerald-500">
<div className="font-mono font-semibold text-2xl text-emerald-500">
${totalRevenue.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Revenue</div>
<div className="text-muted-foreground text-xs">Revenue</div>
</div>
</div>
</FeatureCardContainer>

View File

@@ -1,17 +1,13 @@
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,
ChevronRightIcon,
DollarSignIcon,
LayoutDashboardIcon,
RocketIcon,
WorkflowIcon,
} from 'lucide-react';
import Link from 'next/link';
import { CollaborationChart } from './collaboration-chart';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
const features = [
{
@@ -40,33 +36,33 @@ const features = [
export function Collaboration() {
return (
<Section className="container">
<div className="grid grid-cols-1 md:grid-cols-2 gap-16">
<div className="grid grid-cols-1 gap-16 md:grid-cols-2">
<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."
title="Turn data into actionable insights"
/>
<GetStartedButton className="mt-6" />
<div className="col gap-6 mt-16">
<div className="col mt-16 gap-6">
{features.map((feature) => (
<Link
className="group col relative gap-2 overflow-hidden pr-10"
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.icon className="relative -top-0.5 mr-2 inline-block size-6" />
{feature.title}
</h3>
<p className="text-muted-foreground text-sm">
{feature.description}
</p>
<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
className="absolute top-1/2 right-0 size-5 translate-x-full -translate-y-1/2 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0"
/>
</Link>
))}

View File

@@ -1,18 +1,15 @@
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"
className={cn('text-foreground', className)}
fill="none"
height="539"
viewBox="0 0 409 539"
fill="none"
width="409"
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"
@@ -21,12 +18,12 @@ function Svg({ className }: { className?: string }) {
/>
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
id="paint0_linear_552_3808"
x1="324.18"
y1="186.354"
x2="161.365"
y1="186.354"
y2="265.924"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
@@ -57,23 +54,23 @@ export function CtaBanner({
<div className="container">
<section
className={cn(
'relative overflow-hidden border rounded-3xl py-16 px-4 md:px-16',
'relative overflow-hidden rounded-3xl border px-4 py-16 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 bottom-12 left-12 size-px rounded-full shadow-[0_0_250px_80px_var(--color-foreground)]" />
<div className="absolute top-12 right-12 size-px rounded-full shadow-[0_0_250px_80px_var(--color-foreground)]" />
<Svg className="absolute bottom-0 left-0 -translate-x-1/2 translate-y-1/2 opacity-50 max-md:scale-50" />
<Svg className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 rotate-105 scale-75 opacity-50 max-md:scale-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">
<div className="col center-center container relative z-10 max-w-3xl gap-6">
<h2 className="text-center font-semibold text-4xl md:text-4xl">
{title}
</h2>
<p className="text-muted-foreground text-center max-w-md">
<p className="max-w-md text-center text-muted-foreground">
{description}
</p>
<GetStartedButton className="mt-4" text={ctaText} href={ctaLink} />
<GetStartedButton className="mt-4" href={ctaLink} text={ctaText} />
</div>
</section>
</div>

View File

@@ -1,24 +1,24 @@
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';
import { FeatureCardContainer } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
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"
description="Integrate OpenPanel with your favorite framework using our lightweight SDKs. A few lines of code and you're tracking."
title="Get started in minutes"
/>
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
<div className="grid grid-cols-2 gap-6 md:grid-cols-5">
{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>
<div className="row items-center justify-between">
<span className="font-semibold text-sm">{sdk.name}</span>
<ArrowRightIcon className="size-4" />
</div>
</FeatureCardContainer>

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useMemo, useRef } from 'react';
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 = [
{
@@ -106,12 +106,14 @@ export function Testimonials() {
// Duplicate items to create the illusion of infinite scrolling
const duplicatedTestimonials = useMemo(
() => [...testimonials, ...testimonials],
[],
[]
);
useEffect(() => {
const scrollerElement = scrollerRef.current;
if (!scrollerElement) return;
if (!scrollerElement) {
return;
}
const handleScroll = () => {
// When we've scrolled to the end of the first set, reset to the beginning
@@ -165,21 +167,21 @@ export function Testimonials() {
<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."
title="Loved by builders everywhere"
/>
</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"
className="pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-32"
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"
className="pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-32"
style={{
background:
'linear-gradient(to left, hsl(var(--background)), transparent)',
@@ -187,19 +189,19 @@ export function Testimonials() {
/>
<InfiniteMovingCards
items={testimonials}
direction="left"
pauseOnHover
speed="slow"
className="gap-8"
direction="left"
items={testimonials}
pauseOnHover
renderItem={(item) => (
<TwitterCard
name={item.name}
handle={item.handle}
content={item.content}
avatarUrl={item.avatarUrl}
content={item.content}
handle={item.handle}
name={item.name}
/>
)}
speed="slow"
/>
</div>
</Section>

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { Footer } from '@/components/footer';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
export default function Layout({
children,

View File

@@ -1,11 +1,11 @@
import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration';
import { FeatureSpotlight } from './_sections/feature-spotlight';
import { CtaBanner } from './_sections/cta-banner';
import { DataPrivacy } from './_sections/data-privacy';
import { Faq } from './_sections/faq';
import { MixpanelAlternative } from './_sections/mixpanel-alternative';
import { FeatureSpotlight } from './_sections/feature-spotlight';
import { Hero } from './_sections/hero';
import { MixpanelAlternative } from './_sections/mixpanel-alternative';
import { Pricing } from './_sections/pricing';
import { Sdks } from './_sections/sdks';
import { Testimonials } from './_sections/testimonials';

View File

@@ -23,7 +23,7 @@ export const GET = function POST(req: Request) {
}
return acc;
},
{} as Record<string, string>,
{} as Record<string, string>
),
});
};

View File

@@ -1,5 +1,5 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages

View File

@@ -1,6 +1,4 @@
import * as dns from 'node:dns/promises';
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import { getGeoLocation } from '@openpanel/geo';
import { NextResponse } from 'next/server';
interface IPInfo {
@@ -55,15 +53,25 @@ function checkRateLimit(ip: string): boolean {
function isPrivateIP(ip: string): boolean {
// IPv6 loopback
if (ip === '::1') return true;
if (ip.startsWith('::ffff:127.')) return true;
if (ip === '::1') {
return true;
}
if (ip.startsWith('::ffff:127.')) {
return true;
}
// IPv4 loopback
if (ip.startsWith('127.')) return true;
if (ip.startsWith('127.')) {
return true;
}
// IPv4 private ranges
if (ip.startsWith('10.')) return true;
if (ip.startsWith('192.168.')) return true;
if (ip.startsWith('10.')) {
return true;
}
if (ip.startsWith('192.168.')) {
return true;
}
if (ip.startsWith('172.')) {
const parts = ip.split('.');
if (parts.length >= 2) {
@@ -95,7 +103,7 @@ export async function GET(request: Request) {
if (clientIp && !checkRateLimit(clientIp)) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 },
{ status: 429 }
);
}
@@ -112,17 +120,17 @@ export async function GET(request: Request) {
if (!ipToLookup) {
return NextResponse.json(
{ error: 'No IP address provided or detected' },
{ status: 400 },
{ status: 400 }
);
}
// Validate IP format (basic check)
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (!ipv4Regex.test(ipToLookup) && !ipv6Regex.test(ipToLookup)) {
if (!(ipv4Regex.test(ipToLookup) || ipv6Regex.test(ipToLookup))) {
return NextResponse.json(
{ error: 'Invalid IP address format' },
{ status: 400 },
{ status: 400 }
);
}
@@ -152,7 +160,7 @@ export async function GET(request: Request) {
? error.message
: 'Failed to lookup IP address',
},
{ status: 500 },
{ status: 500 }
);
}
}

View File

@@ -6,7 +6,7 @@ import { getGeoLocation } from '@openpanel/geo';
import * as cheerio from 'cheerio';
import { NextResponse } from 'next/server';
const TIMEOUT_MS = 10000; // 10 seconds
const TIMEOUT_MS = 10_000; // 10 seconds
const MAX_REDIRECTS = 10;
interface RedirectHop {
@@ -118,19 +118,31 @@ function detectCDN(headers: Headers): string | null {
const fastly = headers.get('fastly-request-id');
const cloudfront = headers.get('x-amz-cf-id');
if (cfRay || server.includes('cloudflare')) return 'Cloudflare';
if (vercelId || server.includes('vercel')) return 'Vercel';
if (fastly || server.includes('fastly')) return 'Fastly';
if (cloudfront || server.includes('cloudfront')) return 'CloudFront';
if (server.includes('nginx')) return 'Nginx';
if (server.includes('apache')) return 'Apache';
if (cfRay || server.includes('cloudflare')) {
return 'Cloudflare';
}
if (vercelId || server.includes('vercel')) {
return 'Vercel';
}
if (fastly || server.includes('fastly')) {
return 'Fastly';
}
if (cloudfront || server.includes('cloudfront')) {
return 'CloudFront';
}
if (server.includes('nginx')) {
return 'Nginx';
}
if (server.includes('apache')) {
return 'Apache';
}
return null;
}
async function checkRobotsTxt(
baseUrl: string,
path: string,
path: string
): Promise<'allowed' | 'blocked' | 'error'> {
try {
const robotsUrl = new URL('/robots.txt', baseUrl).toString();
@@ -193,7 +205,7 @@ async function checkSitemap(baseUrl: string): Promise<boolean> {
}
async function getSSLInfo(
hostname: string,
hostname: string
): Promise<SiteCheckResult['technical']['ssl'] | null> {
return new Promise((resolve) => {
const socket = tls.connect(
@@ -207,7 +219,7 @@ async function getSSLInfo(
const cert = socket.getPeerCertificate(true);
socket.destroy();
if (!cert || !cert.valid_to) {
if (!(cert && cert.valid_to)) {
resolve(null);
return;
}
@@ -217,7 +229,7 @@ async function getSSLInfo(
issuer: cert.issuer?.CN || 'Unknown',
expires: cert.valid_to,
});
},
}
);
socket.on('error', () => {
@@ -260,7 +272,7 @@ async function getIPInfo(ip: string): Promise<IPInfo> {
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,status`,
{
signal: controller.signal,
},
}
);
clearTimeout(timeout);
@@ -298,7 +310,7 @@ async function measureDNSLookup(hostname: string): Promise<number> {
async function measureConnectionTime(
hostname: string,
port: number,
port: number
): Promise<{ connectTime: number; tlsTime: number }> {
return new Promise((resolve) => {
const start = Date.now();
@@ -347,7 +359,7 @@ async function measureConnectionTime(
async function fetchWithRedirects(
url: string,
maxRedirects: number = MAX_REDIRECTS,
maxRedirects: number = MAX_REDIRECTS
): Promise<{
finalUrl: string;
redirectChain: RedirectHop[];
@@ -490,10 +502,18 @@ async function fetchWithRedirects(
function calculateSecurityScore(security: SiteCheckResult['security']): number {
let score = 0;
if (security.csp) score += 25;
if (security.xFrameOptions) score += 15;
if (security.xContentTypeOptions) score += 15;
if (security.hsts) score += 25;
if (security.csp) {
score += 25;
}
if (security.xFrameOptions) {
score += 15;
}
if (security.xContentTypeOptions) {
score += 15;
}
if (security.hsts) {
score += 25;
}
// Additional points for proper values
if (
security.xFrameOptions?.toLowerCase() === 'deny' ||
@@ -514,7 +534,7 @@ export async function GET(request: Request) {
if (!urlParam) {
return NextResponse.json(
{ error: 'URL parameter is required' },
{ status: 400 },
{ status: 400 }
);
}
@@ -523,7 +543,7 @@ export async function GET(request: Request) {
if (ip && !checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 },
{ status: 429 }
);
}
@@ -535,7 +555,7 @@ export async function GET(request: Request) {
}
// Ensure protocol
if (!url.protocol || !url.protocol.startsWith('http')) {
if (!(url.protocol && url.protocol.startsWith('http'))) {
url = new URL(`https://${urlParam}`);
}
@@ -606,7 +626,7 @@ export async function GET(request: Request) {
// Robots.txt check
const robotsTxtStatus = await checkRobotsTxt(
finalUrl.toString(),
finalUrlObj.pathname,
finalUrlObj.pathname
);
// Sitemap check
@@ -688,7 +708,7 @@ export async function GET(request: Request) {
error:
error instanceof Error ? error.message : 'Failed to analyze site',
},
{ status: 500 },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,3 @@
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,
@@ -11,6 +7,10 @@ import {
} from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { source } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
type PageProps = {
params: Promise<{ slug: string[] }>;
@@ -19,12 +19,14 @@ type PageProps = {
export default async function Page(props: PageProps) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
if (!page) {
notFound();
}
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsPage full={page.data.full} toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
@@ -46,7 +48,9 @@ export async function generateStaticParams() {
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
if (!page) {
notFound();
}
return getPageMetadata({
title: page.data.title,

View File

@@ -1,6 +1,6 @@
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
export default function Layout({ children }: { children: React.ReactNode }) {
return (

View File

@@ -96,17 +96,17 @@ export default function DpaDownloadPage() {
require it for their own compliance documentation and records of
processing activities.
</p>
<p className="mb-1 text-gray-700 text-sm font-semibold">
<p className="mb-1 font-semibold text-gray-700 text-sm">
Session replay (optional feature)
</p>
<p className="text-gray-700 text-sm">
OpenPanel optionally supports session replay, which must be
explicitly enabled by the Controller. When enabled, session replay
records DOM snapshots and user interactions (mouse movements, clicks,
scrolls) using rrweb. All text content and form inputs are masked by
default. The Controller is responsible for ensuring their use of
session replay complies with applicable privacy law, including
providing appropriate notice to end users.
records DOM snapshots and user interactions (mouse movements,
clicks, scrolls) using rrweb. All text content and form inputs are
masked by default. The Controller is responsible for ensuring their
use of session replay complies with applicable privacy law,
including providing appropriate notice to end users.
</p>
</Section>
@@ -331,10 +331,11 @@ export default function DpaDownloadPage() {
permanently deleted.
</li>
<li>
The Controller can delete individual projects, all associated data,
or their entire account at any time from within the dashboard. Upon
account termination, OpenPanel will delete the Controller's data
within 30 days unless required by law to retain it longer.
The Controller can delete individual projects, all associated
data, or their entire account at any time from within the
dashboard. Upon account termination, OpenPanel will delete the
Controller's data within 30 days unless required by law to retain
it longer.
</li>
</ul>
</Section>
@@ -426,7 +427,7 @@ export default function DpaDownloadPage() {
{/* Controller - blank */}
<div>
<div className="flex flex-col h-32 gap-2">
<div className="flex h-32 flex-col gap-2">
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
Controller
</p>

View File

@@ -1,6 +1,6 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@custom-variant dark (&:is(.dark *));
@custom-variant light (&:is(.light *));
@@ -14,7 +14,7 @@ body {
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
:root {
@@ -60,7 +60,7 @@ code {
@apply max-w-[1000px]
}
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
@@ -129,13 +129,16 @@ code {
--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;
--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;
--animate-scroll: scroll var(--animation-duration, 40s)
var(--animation-direction, forwards) linear infinite;
@keyframes scroll {
to {
transform: translate(calc(-50% - 0.5rem));
@@ -183,26 +186,26 @@ code {
padding: 16px;
}
.line-before:before {
content: '';
content: "";
display: block;
position: absolute;
top: calc(4px*-32);
bottom: calc(4px*-32);
top: calc(4px * -32);
bottom: calc(4px * -32);
left: 0;
width: 1px;
background: hsl(var(--foreground)/0.1);
background: hsl(var(--foreground) / 0.1);
}
.line-after {
position: relative;
padding: 16px;
}
.line-after:after {
content: '';
content: "";
display: block;
position: absolute;
top: calc(4px*-32);
bottom: calc(4px*-32);
top: calc(4px * -32);
bottom: calc(4px * -32);
right: 0;
width: 1px;
background: hsl(var(--foreground)/0.1);
background: hsl(var(--foreground) / 0.1);
}

View File

@@ -1,8 +1,4 @@
import {
OPENPANEL_BASE_URL,
OPENPANEL_DESCRIPTION,
OPENPANEL_NAME,
} from '@/lib/openpanel-brand';
import { OPENPANEL_DESCRIPTION, OPENPANEL_NAME } from '@/lib/openpanel-brand';
import { getLLMText, source } from '@/lib/source';
export const dynamic = 'force-static';
@@ -16,7 +12,10 @@ This file contains the full text of all documentation pages. Each section is sep
`;
export async function GET() {
const pages = source.getPages().slice().sort((a, b) => a.url.localeCompare(b.url));
const pages = source
.getPages()
.slice()
.sort((a, b) => a.url.localeCompare(b.url));
const scanned = await Promise.all(pages.map(getLLMText));
return new Response(header + scanned.join('\n\n'), {

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { OPENPANEL_BASE_URL } from '@/lib/openpanel-brand';
import { articleSource, guideSource, pageSource, source } from '@/lib/source';
import { NextResponse } from 'next/server';
const ALLOWED_PAGE_PATHS = new Set([
'privacy',
@@ -68,8 +68,9 @@ export async function GET(request: Request) {
.replace(/^\/articles\/?/, '')
.split('/')
.filter(Boolean);
if (slug.length === 0)
if (slug.length === 0) {
return new NextResponse('Not found', { status: 404 });
}
const page = articleSource.getPage(slug);
if (!page) {
return new NextResponse('Not found', { status: 404 });
@@ -89,8 +90,9 @@ export async function GET(request: Request) {
.replace(/^\/guides\/?/, '')
.split('/')
.filter(Boolean);
if (slug.length === 0)
if (slug.length === 0) {
return new NextResponse('Not found', { status: 404 });
}
const page = guideSource.getPage(slug);
if (!page) {
return new NextResponse('Not found', { status: 404 });
@@ -133,7 +135,7 @@ export async function GET(request: Request) {
stubMarkdown(`${OPENPANEL_BASE_URL}/${segment}`, path),
{
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
},
}
);
}
}
@@ -142,7 +144,7 @@ export async function GET(request: Request) {
stubMarkdown(`${OPENPANEL_BASE_URL}/${segment}`, path),
{
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
},
}
);
}
}

View File

@@ -1,5 +1,5 @@
import { getPageMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
import { getPageMetadata } from '@/lib/metadata';
export const metadata: Metadata = getPageMetadata({
url: '/tools/ip-lookup',

View File

@@ -1,22 +1,13 @@
'use client';
import { AlertCircle, Globe, Loader2, MapPin, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { FaqItem, Faqs } from '@/components/faq';
import { FeatureCardContainer } from '@/components/feature-card';
import { SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import {
AlertCircle,
Building2,
Globe,
Loader2,
MapPin,
Network,
Search,
Server,
} from 'lucide-react';
import { useEffect, useState } from 'react';
interface IPInfo {
ip: string;
@@ -55,7 +46,7 @@ export default function IPLookupPage() {
if (response.status === 429) {
setIsRateLimited(true);
throw new Error(
'Rate limit exceeded. Please wait a minute before trying again.',
'Rate limit exceeded. Please wait a minute before trying again.'
);
}
setIsRateLimited(false);
@@ -77,7 +68,9 @@ export default function IPLookupPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!ip.trim()) return;
if (!ip.trim()) {
return;
}
setLoading(true);
setError(null);
@@ -85,7 +78,7 @@ export default function IPLookupPage() {
try {
const response = await fetch(
`/api/tools/ip-lookup?ip=${encodeURIComponent(ip.trim())}`,
`/api/tools/ip-lookup?ip=${encodeURIComponent(ip.trim())}`
);
const data = await response.json();
@@ -93,7 +86,7 @@ export default function IPLookupPage() {
if (response.status === 429) {
setIsRateLimited(true);
throw new Error(
'Rate limit exceeded. Please wait a minute before trying again.',
'Rate limit exceeded. Please wait a minute before trying again.'
);
}
setIsRateLimited(false);
@@ -122,14 +115,14 @@ export default function IPLookupPage() {
className?: string;
}) => (
<FeatureCardContainer
className={cn('p-4 flex items-start gap-3', className)}
className={cn('flex items-start gap-3 p-4', className)}
>
<div className="text-muted-foreground mt-0.5">{icon}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-muted-foreground mb-1">
<div className="mt-0.5 text-muted-foreground">{icon}</div>
<div className="min-w-0 flex-1">
<div className="mb-1 font-medium text-muted-foreground text-sm">
{label}
</div>
<div className="text-base font-semibold break-words">
<div className="break-words font-semibold text-base">
{value || '—'}
</div>
</div>
@@ -137,35 +130,37 @@ export default function IPLookupPage() {
);
const getCountryFlag = (countryCode?: string): string => {
if (!countryCode || countryCode.length !== 2) return '🌐';
if (!countryCode || countryCode.length !== 2) {
return '🌐';
}
// Convert country code to flag emoji
const codePoints = countryCode
.toUpperCase()
.split('')
.map((char) => 127397 + char.charCodeAt(0));
.map((char) => 127_397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
return (
<div className="max-w-4xl">
<SectionHeader
title="IP Lookup Tool"
description="Find detailed information about any IP address including geolocation, ISP, and network details."
variant="default"
as="h1"
description="Find detailed information about any IP address including geolocation, ISP, and network details."
title="IP Lookup Tool"
variant="default"
/>
<form onSubmit={handleSubmit} className="mt-8">
<form className="mt-8" onSubmit={handleSubmit}>
<div className="flex gap-2">
<Input
type="text"
placeholder="Enter IP address or leave empty to detect yours"
value={ip}
onChange={(e) => setIp(e.target.value)}
className="flex-1"
onChange={(e) => setIp(e.target.value)}
placeholder="Enter IP address or leave empty to detect yours"
size="lg"
type="text"
value={ip}
/>
<Button type="submit" disabled={loading || autoDetecting} size="lg">
<Button disabled={loading || autoDetecting} size="lg" type="submit">
{loading || autoDetecting ? (
<>
<Loader2 className="size-4 animate-spin" />
@@ -184,18 +179,18 @@ export default function IPLookupPage() {
{error && (
<div
className={cn(
'mt-4 p-4 rounded-lg border',
'mt-4 rounded-lg border p-4',
isRateLimited
? 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
: 'bg-destructive/10 border-destructive/20 text-destructive',
? 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-destructive/20 bg-destructive/10 text-destructive'
)}
>
<div className="flex items-start gap-2">
<AlertCircle className="size-5 mt-0.5 flex-shrink-0" />
<AlertCircle className="mt-0.5 size-5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">{error}</div>
{isRateLimited && (
<div className="text-sm mt-1 opacity-90">
<div className="mt-1 text-sm opacity-90">
You can make up to 20 requests per minute. Please try again
shortly.
</div>
@@ -209,17 +204,17 @@ export default function IPLookupPage() {
<div className="mt-8 space-y-6">
{/* IP Address Display */}
<FeatureCardContainer>
<div className="flex items-center gap-3 mb-2">
<div className="mb-2 flex items-center gap-3">
<Globe className="size-6" />
<div>
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
{autoDetecting ? 'Detected IP Address' : 'IP Address'}
</div>
<div className="text-2xl font-bold font-mono">{result.ip}</div>
<div className="font-bold font-mono text-2xl">{result.ip}</div>
</div>
</div>
{(result.isLocalhost || result.isPrivate) && (
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
<div className="mt-3 flex items-center gap-2 text-muted-foreground text-sm">
<AlertCircle className="size-4" />
<span>
{result.isLocalhost
@@ -233,11 +228,11 @@ export default function IPLookupPage() {
{/* Location Information */}
{result.location.country && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<h3 className="mb-4 flex items-center gap-2 font-semibold text-lg">
<MapPin className="size-5" />
Location Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoCard
icon={<Globe className="size-5" />}
label="Country"
@@ -278,40 +273,40 @@ export default function IPLookupPage() {
{/* Map Preview */}
{result.location.latitude && result.location.longitude && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<h3 className="mb-4 flex items-center gap-2 font-semibold text-lg">
<MapPin className="size-5" />
Map Location
</h3>
<div className="border border-border rounded-lg overflow-hidden bg-card">
<div className="overflow-hidden rounded-lg border border-border bg-card">
<iframe
width="100%"
height="400"
className="aspect-video w-full"
frameBorder="0"
scrolling="no"
height="400"
marginHeight={0}
marginWidth={0}
scrolling="no"
src={`https://www.openstreetmap.org/export/embed.html?bbox=${result.location.longitude - 0.1},${result.location.latitude - 0.1},${result.location.longitude + 0.1},${result.location.latitude + 0.1}&layer=mapnik&marker=${result.location.latitude},${result.location.longitude}`}
className="w-full aspect-video"
title="Map location"
width="100%"
/>
<div className="p-2 bg-muted border-t border-border text-xs text-center text-muted-foreground flex items-center justify-between gap-4">
<div className="flex items-center justify-between gap-4 border-border border-t bg-muted p-2 text-center text-muted-foreground text-xs">
<div>
©{' '}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
href="https://www.openstreetmap.org/copyright"
rel="noopener noreferrer"
target="_blank"
>
OpenStreetMap
</a>{' '}
contributors
</div>
<a
href={`https://www.openstreetmap.org/?mlat=${result.location.latitude}&mlon=${result.location.longitude}#map=12/${result.location.latitude}/${result.location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
href={`https://www.openstreetmap.org/?mlat=${result.location.latitude}&mlon=${result.location.longitude}#map=12/${result.location.latitude}/${result.location.longitude}`}
rel="noopener noreferrer"
target="_blank"
>
View Larger Map
</a>
@@ -321,14 +316,14 @@ export default function IPLookupPage() {
)}
{/* Attribution */}
<div className="pt-4 border-t text-xs text-muted-foreground space-y-2">
<div className="space-y-2 border-t pt-4 text-muted-foreground text-xs">
<div>
<strong>Location data:</strong> Powered by{' '}
<a
href="https://www.maxmind.com/en/geoip2-services-and-databases"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
href="https://www.maxmind.com/en/geoip2-services-and-databases"
rel="noopener noreferrer"
target="_blank"
>
MaxMind GeoLite2
</a>{' '}
@@ -338,10 +333,10 @@ export default function IPLookupPage() {
<div>
<strong>Network data:</strong> ISP/ASN information from{' '}
<a
href="https://ip-api.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
href="https://ip-api.com"
rel="noopener noreferrer"
target="_blank"
>
ip-api.com
</a>
@@ -352,13 +347,13 @@ export default function IPLookupPage() {
)}
{/* SEO Content Section */}
<div className="mt-16 prose prose-neutral dark:prose-invert max-w-none">
<div className="prose prose-neutral dark:prose-invert mt-16 max-w-none">
<article className="space-y-8">
<div>
<h2 className="text-3xl font-bold mb-4">
<h2 className="mb-4 font-bold text-3xl">
Free IP Lookup Tool - Find Your IP Address and Geolocation
</h2>
<p className="text-lg text-muted-foreground mb-6">
<p className="mb-6 text-lg text-muted-foreground">
Discover your IP address instantly and get detailed geolocation
information including country, city, ISP, ASN, and network
details. Our free IP lookup tool provides accurate location data
@@ -367,7 +362,7 @@ export default function IPLookupPage() {
</div>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
What is an IP Address?
</h3>
<p className="mb-4">
@@ -376,7 +371,7 @@ export default function IPLookupPage() {
it as a mailing address for your device on the internet. There are
two main types:
</p>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>
<strong>IPv4:</strong> The most common format, consisting of
four numbers separated by dots (e.g., 192.168.1.1)
@@ -390,7 +385,7 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
How IP Geolocation Works
</h3>
<p className="mb-4">
@@ -398,7 +393,7 @@ export default function IPLookupPage() {
IP address by analyzing routing information and regional IP
address allocations. Our tool uses:
</p>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>
<strong>MaxMind GeoLite2 Database:</strong> Industry-standard
geolocation database providing accurate city and country-level
@@ -426,15 +421,15 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
Understanding IP Lookup Results
</h3>
<h4 className="text-xl font-semibold mt-6 mb-3">Location Data</h4>
<h4 className="mt-6 mb-3 font-semibold text-xl">Location Data</h4>
<p className="mb-4">
Our IP lookup provides detailed geographic information:
</p>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>
<strong>Country:</strong> The country where the IP address is
registered or routed through
@@ -451,11 +446,11 @@ export default function IPLookupPage() {
</li>
</ul>
<h4 className="text-xl font-semibold mt-6 mb-3">
<h4 className="mt-6 mb-3 font-semibold text-xl">
Network Information
</h4>
<p className="mb-4">Technical details about the network:</p>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>
<strong>ISP (Internet Service Provider):</strong> The company
providing internet service for this IP address
@@ -476,10 +471,10 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
Common Use Cases for IP Lookup
</h3>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>
<strong>Security:</strong> Identify suspicious login attempts or
track potential security threats
@@ -508,7 +503,7 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
Public vs Private IP Addresses
</h3>
<p className="mb-4">
@@ -516,7 +511,7 @@ export default function IPLookupPage() {
addresses:
</p>
<h4 className="text-xl font-semibold mt-6 mb-3">
<h4 className="mt-6 mb-3 font-semibold text-xl">
Public IP Address
</h4>
<p className="mb-4">
@@ -525,7 +520,7 @@ export default function IPLookupPage() {
detects your public IP address automatically.
</p>
<h4 className="text-xl font-semibold mt-6 mb-3">
<h4 className="mt-6 mb-3 font-semibold text-xl">
Private IP Address
</h4>
<p className="mb-4">
@@ -533,7 +528,7 @@ export default function IPLookupPage() {
etc.) and are not routable on the public internet. Common private
IP ranges include:
</p>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>10.0.0.0 to 10.255.255.255</li>
<li>172.16.0.0 to 172.31.255.255</li>
<li>192.168.0.0 to 192.168.255.255</li>
@@ -545,7 +540,7 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
Privacy and IP Addresses
</h3>
<p className="mb-4">
@@ -553,7 +548,7 @@ export default function IPLookupPage() {
network, but it doesn't expose your exact physical address or
personal identity. Here's what you should know:
</p>
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
<ul className="mb-6 ml-4 list-inside list-disc space-y-2">
<li>
IP addresses show general location (city/region level), not
exact addresses
@@ -571,10 +566,10 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
How to Use Our IP Lookup Tool
</h3>
<ol className="list-decimal list-inside space-y-3 mb-6 ml-4">
<ol className="mb-6 ml-4 list-inside list-decimal space-y-3">
<li>
<strong>Auto-Detection:</strong> When you visit the page, your
IP address is automatically detected and displayed
@@ -595,7 +590,7 @@ export default function IPLookupPage() {
</section>
<section>
<h3 className="text-2xl font-semibold mb-4">
<h3 className="mb-4 font-semibold text-2xl">
Frequently Asked Questions
</h3>
@@ -633,8 +628,8 @@ export default function IPLookupPage() {
</Faqs>
</section>
<section className="border-t pt-8 mt-8">
<h3 className="text-2xl font-semibold mb-4">
<section className="mt-8 border-t pt-8">
<h3 className="mb-4 font-semibold text-2xl">
Start Using Our Free IP Lookup Tool
</h3>
<p className="mb-6">

View File

@@ -1,7 +1,7 @@
import { Footer } from '@/components/footer';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
import ToolsSidebar from './tools-sidebar';
import { Footer } from '@/components/footer';
import Navbar from '@/components/navbar';
export default function ToolsLayout({
children,
@@ -11,9 +11,9 @@ export default function ToolsLayout({
return (
<>
<Navbar />
<div className="min-h-screen mt-12 md:mt-32">
<div className="mt-12 min-h-screen md:mt-32">
<div className="container py-8 md:py-12">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-4">
<main className="lg:col-span-3">{children}</main>
<ToolsSidebar />
</div>

View File

@@ -1,9 +1,9 @@
'use client';
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { TOOLS } from './tools';
import { cn } from '@/lib/utils';
export default function ToolsSidebar(): React.ReactElement {
const pathname = usePathname();
@@ -17,21 +17,21 @@ export default function ToolsSidebar(): React.ReactElement {
const isActive = pathname === tool.url;
return (
<Link
key={tool.url}
href={tool.url}
className={cn(
'flex items-start gap-3 p-3 rounded-lg transition-colors',
'flex items-start gap-3 rounded-lg p-3 transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 text-muted-foreground hover:text-foreground',
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
href={tool.url}
key={tool.url}
>
<Icon className="size-5 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<Icon className="mt-0.5 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{tool.name}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
<p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs">
{tool.description}
</p>
</div>

View File

@@ -1,10 +1,4 @@
import {
GlobeIcon,
Link2Icon,
QrCodeIcon,
SearchIcon,
TimerIcon,
} from 'lucide-react';
import { GlobeIcon, SearchIcon } from 'lucide-react';
export const TOOLS = [
{

View File

@@ -1,5 +1,5 @@
import { getPageMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
import { getPageMetadata } from '@/lib/metadata';
export const metadata: Metadata = getPageMetadata({
url: '/tools/url-checker',

File diff suppressed because it is too large Load Diff

View File

@@ -20,24 +20,23 @@ export function SocialPreview({
const hasImage = !!image;
return (
<div className="border border-border rounded-lg overflow-hidden bg-card shadow-sm">
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
{/* Platform header */}
<div className="px-3 py-2 bg-muted border-b border-border flex items-center gap-2">
<div className="flex items-center gap-2 border-border border-b bg-muted px-3 py-2">
<img
src={`https://api.openpanel.dev/misc/favicon?url=${encodeURIComponent(url)}`}
alt="Favicon"
className="size-4"
src={`https://api.openpanel.dev/misc/favicon?url=${encodeURIComponent(url)}`}
/>
<span className="text-xs font-semibold text-foreground">{domain}</span>
<span className="font-semibold text-foreground text-xs">{domain}</span>
</div>
{/* Image */}
{hasImage ? (
<div className="relative w-full aspect-[1.91/1] bg-muted">
<div className="relative aspect-[1.91/1] w-full bg-muted">
<img
src={image}
alt=""
className="w-full h-full object-cover"
className="h-full w-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
@@ -47,26 +46,27 @@ export function SocialPreview({
'<div class="w-full h-full flex items-center justify-center text-muted-foreground text-sm">Image failed to load</div>';
}
}}
src={image}
/>
</div>
) : (
<div className="w-full aspect-[1.91/1] bg-muted flex items-center justify-center">
<div className="flex aspect-[1.91/1] w-full items-center justify-center bg-muted">
<span className="text-muted-foreground text-sm">No image</span>
</div>
)}
{/* Content */}
<div className="p-3 space-y-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
<div className="space-y-1 p-3">
<div className="text-muted-foreground text-xs uppercase tracking-wide">
{domain}
</div>
<div className="text-base font-semibold text-foreground line-clamp-2">
<div className="line-clamp-2 font-semibold text-base text-foreground">
{displayTitle}
</div>
<div className="text-sm text-muted-foreground line-clamp-2">
<div className="line-clamp-2 text-muted-foreground text-sm">
{displayDescription}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground pt-1">
<div className="flex items-center gap-1 pt-1 text-muted-foreground text-xs">
<ExternalLink className="size-3" />
<span className="truncate">{url}</span>
</div>

View File

@@ -18,23 +18,23 @@ export function ArticleCard({
}) {
return (
<Link
className="col overflow-hidden rounded-lg border bg-background-light transition-all duration-300 hover:scale-105 hover:shadow-background-dark hover:shadow-lg"
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"
height={181}
src={cover}
width={323}
/>
<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 className="col flex-1 p-4">
{tag && <span className="mb-2 font-mono text-xs">{tag}</span>}
<span className="mb-6 flex-1">
<h2 className="font-semibold text-xl">{title}</h2>
</span>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
</p>
</span>

View File

@@ -45,31 +45,31 @@ export function Competition() {
if (!mounted) {
return (
<span className="block truncate leading-tight -mt-1" style={{ color }}>
<span className="-mt-1 block truncate leading-tight" style={{ color }}>
{word}
</span>
);
}
return (
<AnimatePresence mode="wait" initial={false}>
<AnimatePresence initial={false} mode="wait">
<motion.div
className="-mt-1 block truncate leading-tight"
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 }}
initial={{ y: 10, opacity: 0 }}
key={`${word}-${char}-${index.toString()}`}
style={{ display: 'inline-block', whiteSpace: 'pre' }}
transition={{
duration: 0.15,
delay: index * 0.015,
ease: 'easeOut',
}}
style={{ display: 'inline-block', whiteSpace: 'pre' }}
>
{char}
</motion.span>

View File

@@ -27,8 +27,8 @@ export function EuFlag({ className }: { className?: string }) {
{STARS.map((s, i) => (
<polygon
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
fill="#FFCC00"
key={i}
points={star(s.x, s.y, 1.1, 0.45)}
/>
))}

View File

@@ -1,8 +1,7 @@
import type { LucideIcon } from 'lucide-react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { FeatureCardHoverTrack } from '@/components/feature-card-hover-track';
import { cn } from '@/lib/utils';
interface FeatureCardProps {
link?: {

View File

@@ -1,22 +1,27 @@
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { cn } from '@/lib/utils';
export function Figure({
src,
alt,
caption,
className,
}: { src: string; alt: string; caption: string; className?: string }) {
}: {
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"
height={800}
src={src}
width={1200}
/>
<figcaption className="text-center text-sm text-muted-foreground mt-2">
<figcaption className="mt-2 text-center text-muted-foreground text-sm">
{caption}
</figcaption>
</figure>

View File

@@ -45,15 +45,15 @@ export function FlowStep({
const Icon = iconMap[icon];
return (
<div className="relative flex gap-4 mb-4 min-w-0">
<div className="relative mb-4 flex min-w-0 gap-4">
{/* Step number and icon */}
<div className="flex flex-col items-center shrink-0">
<div className="flex shrink-0 flex-col items-center">
<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">
<div className="flex size-10 items-center justify-center rounded-full bg-primary font-semibold text-primary-foreground 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'}`}
className={`absolute -right-2 -bottom-2 flex h-6 w-6 items-center justify-center rounded-full border bg-background shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
>
<Icon
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
@@ -62,14 +62,14 @@ export function FlowStep({
</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 className="mt-2 min-h-[2rem] w-0.5 flex-1 bg-border" />
)}
</div>
{/* Content */}
<div className="flex-1 pt-1 min-w-0">
<div className="min-w-0 flex-1 pt-1">
<div className="mb-2">
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
<span className="mr-2 font-semibold text-foreground">{actor}:</span>{' '}
<span className="text-muted-foreground">{description}</span>
</div>
{children && <div className="mt-3 min-w-0">{children}</div>}

View File

@@ -1,7 +1,7 @@
import { getGithubRepoInfo } from '@/lib/github';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
import { getGithubRepoInfo } from '@/lib/github';
function formatStars(stars: number) {
if (stars >= 1000) {
@@ -12,7 +12,7 @@ function formatStars(stars: number) {
}
export function GithubButton() {
const [stars, setStars] = useState(4_800);
const [stars, setStars] = useState(4800);
useEffect(() => {
getGithubRepoInfo().then((res) => {
if (res?.stargazers_count) {
@@ -21,18 +21,18 @@ export function GithubButton() {
});
}, []);
return (
<Button variant={'secondary'} asChild>
<Link href="https://git.new/openpanel" className="hidden md:flex">
<Button asChild variant={'secondary'}>
<Link className="hidden md:flex" href="https://git.new/openpanel">
<svg
className="w-5 h-5"
aria-hidden="true"
className="h-5 w-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"
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"
fillRule="evenodd"
/>
</svg>
{formatStars(stars)} stars

View File

@@ -33,32 +33,32 @@ export function GuideCard({
}) {
return (
<Link
className="col overflow-hidden rounded-lg border bg-background-light transition-all duration-300 hover:scale-105 hover:shadow-background-dark hover:shadow-lg"
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"
height={181}
src={cover}
width={323}
/>
<span className="p-4 col flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="col flex-1 p-4">
<div className="mb-2 flex items-center gap-2">
<span
className={`font-mono text-xs px-2 py-1 rounded ${difficultyColors[difficulty]}`}
className={`rounded px-2 py-1 font-mono text-xs ${difficultyColors[difficulty]}`}
>
{difficultyLabels[difficulty]}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{timeToComplete} min
</span>
</div>
<span className="flex-1 mb-6">
<h2 className="text-xl font-semibold">{title}</h2>
<span className="mb-6 flex-1">
<h2 className="font-semibold text-xl">{title}</h2>
</span>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
</p>
</span>

View File

@@ -7,7 +7,7 @@ const variantB = [28, 30, 32, 35, 38, 37, 40, 42, 44, 43, 47, 50];
export function ConversionsIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
<div className="col h-full gap-3 px-4 pt-5 pb-3">
{/* A/B variant cards */}
<div className="row gap-3">
<div className="col flex-1 gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
@@ -30,7 +30,7 @@ export function ConversionsIllustration() {
Variant B
</span>
</div>
<span className="font-bold font-mono text-xl text-emerald-500">
<span className="font-bold font-mono text-emerald-500 text-xl">
41.2%
</span>
<SimpleChart
@@ -44,7 +44,7 @@ export function ConversionsIllustration() {
{/* Breakdown label */}
<div className="col gap-1 rounded-xl border bg-card/60 px-3 py-2.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
Breakdown by experiment variant
</span>
<div className="row items-center gap-2">

View File

@@ -1,5 +1,3 @@
import React from 'react';
type IllustrationProps = {
className?: string;
};
@@ -12,15 +10,8 @@ export function DataOwnershipIllustration({
{/* 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">
<div className="col-span-3 rounded-2xl border border-border bg-card/80 p-3 shadow-xl backdrop-blur transition-all duration-300 group-hover:-translate-x-0.5 group-hover:-translate-y-1 sm:p-4">
<div className="flex items-center justify-between text-foreground text-xs">
<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" />
@@ -31,15 +22,15 @@ export function DataOwnershipIllustration({
{/* "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">
<div className="flex-1 rounded-xl border border-border bg-card/80 px-3 py-2">
<p className="text-[10px] text-muted-foreground">Region</p>
<p className="text-xs font-medium text-foreground">
<p className="font-medium text-foreground text-xs">
EU / Custom
</p>
</div>
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
<div className="flex-1 rounded-xl border border-border bg-card/80 px-3 py-2">
<p className="text-[10px] text-muted-foreground">Retention</p>
<p className="text-xs font-medium text-foreground">
<p className="font-medium text-foreground text-xs">
Configurable
</p>
</div>
@@ -74,15 +65,8 @@ export function DataOwnershipIllustration({
</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>
<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-x-0.5 group-hover:translate-y-1 group-hover:opacity-70">
<p className="mb-2 text-muted-foreground text-xs">or use our cloud</p>
<ul className="space-y-1.5">
<li className="flex items-center gap-1.5">

View File

@@ -1,5 +1,3 @@
import React from 'react';
type IllustrationProps = {
className?: string;
};
@@ -10,15 +8,8 @@ export function PrivacyIllustration({ className = '' }: IllustrationProps) {
{/* 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">
<div className="absolute top-0 right-10 bottom-10 left-0 rounded-2xl border border-border/80 bg-card/70 shadow-lg backdrop-blur-sm 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-muted-foreground text-xs">
<span>Session duration</span>
<span className="flex items-center gap-1">
3m 12s
@@ -29,45 +20,37 @@ export function PrivacyIllustration({ className = '' }: IllustrationProps) {
{/* Simple line chart */}
<div className="mt-3 px-4">
<svg
viewBox="0 0 120 40"
className="h-16 w-full text-muted-foreground"
viewBox="0 0 120 40"
>
<path
className="opacity-60"
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"
strokeWidth="2"
/>
<circle cx="118" cy="14" r="2.5" className="fill-blue-400" />
<circle className="fill-blue-400" cx="118" cy="14" r="2.5" />
</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">
<div className="col absolute top-10 right-0 bottom-0 left-4 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-foreground text-xs">
<span>Anonymous visitors</span>
<span className="text-[10px] rounded-full bg-card px-2 py-0.5 text-muted-foreground">
<span className="rounded-full bg-card px-2 py-0.5 text-[10px] 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">
<p className="mb-1 text-[11px] text-muted-foreground">
Active now
</p>
<p className="text-2xl font-semibold text-foreground">128</p>
<p className="font-semibold text-2xl text-foreground">128</p>
</div>
<div className="space-y-1.5 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
@@ -82,12 +65,12 @@ export function PrivacyIllustration({ className = '' }: IllustrationProps) {
</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">
<div className="mt-auto flex gap-2 border-border border-t px-3 py-2.5 text-[11px]">
<div className="flex flex-1 items-center justify-between rounded-xl bg-card/90 px-3 py-1.5">
<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">
<div className="flex flex-1 items-center justify-between rounded-xl bg-card/90 px-3 py-1.5">
<span className="text-muted-foreground">Organic</span>
<span className="text-foreground">58%</span>
</div>

View File

@@ -1,10 +1,6 @@
'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();
@@ -12,7 +8,7 @@ function useFunnelSteps() {
{
id: 'Visitors',
label: 'Visitors',
value: 10000,
value: 10_000,
percentage: 100,
color: resolvedTheme === 'dark' ? '#333' : '#888',
},
@@ -46,7 +42,6 @@ export const PartLabel = ({ part }: { part: any }) => {
return (
<g transform={`translate(${part.x}, ${part.y})`}>
<text
textAnchor="middle"
dominantBaseline="central"
style={{
fill: resolvedTheme === 'dark' ? '#fff' : '#000',
@@ -54,6 +49,7 @@ export const PartLabel = ({ part }: { part: any }) => {
fontSize: 12,
fontWeight: 500,
}}
textAnchor="middle"
>
{part.data.label}
</text>
@@ -77,24 +73,24 @@ function FunnelVisualization() {
}));
return (
<div className="w-full h-full">
<div className="h-full w-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}
beforeSeparatorLength={0}
beforeSeparatorOffset={0}
currentPartSizeExtension={5}
borderWidth={20}
colors={colors}
currentBorderWidth={15}
tooltip={() => null}
currentPartSizeExtension={5}
data={nivoData}
direction="horizontal"
enableAfterSeparators={false}
enableBeforeSeparators={false}
layers={['parts', Labels]}
margin={{ top: 20, right: 0, bottom: 20, left: 0 }}
shapeBlending={0.6}
tooltip={() => null}
/>
</div>
);

View File

@@ -20,36 +20,36 @@ function cellStyle(v: number | null) {
const opacity = 0.12 + (v / 100) * 0.7;
return {
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
borderColor: `rgba(34, 197, 94, 0.3)`,
borderColor: 'rgba(34, 197, 94, 0.3)',
color: v > 55 ? 'rgba(0,0,0,0.75)' : 'var(--foreground)',
};
}
export function RetentionIllustration() {
return (
<div className="h-full px-4 pb-3 pt-5">
<div className="h-full px-4 pt-5 pb-3">
<div className="col h-full gap-1.5">
<div className="row gap-1">
<div className="w-12 shrink-0" />
{headers.map((h) => (
<div
key={h}
className="flex-1 text-center text-[9px] text-muted-foreground"
key={h}
>
{h}
</div>
))}
</div>
{cohorts.map(({ label, values }) => (
<div key={label} className="row flex-1 gap-1">
<div className="row flex-1 gap-1" key={label}>
<div className="flex w-12 shrink-0 items-center text-[9px] text-muted-foreground">
{label}
</div>
{values.map((v, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static data
className="flex flex-1 items-center justify-center rounded border font-medium text-[9px] transition-all duration-300 group-hover:scale-[1.03]"
key={i}
className="flex flex-1 items-center justify-center rounded border text-[9px] font-medium transition-all duration-300 group-hover:scale-[1.03]"
style={cellStyle(v)}
>
{v !== null ? `${v}%` : '—'}

View File

@@ -13,20 +13,22 @@ const referrers = [
export function RevenueIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
<div className="col h-full gap-3 px-4 pt-5 pb-3">
{/* MRR stat + chart */}
<div className="row gap-3">
<div className="col gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
MRR
</span>
<span className="font-bold font-mono text-xl text-emerald-500">
<span className="font-bold font-mono text-emerald-500 text-xl">
$8,420
</span>
<span className="text-[9px] text-emerald-500"> 12% this month</span>
</div>
<div className="col flex-1 gap-1 rounded-xl border bg-card px-3 py-2">
<span className="text-[9px] text-muted-foreground">MRR over time</span>
<span className="text-[9px] text-muted-foreground">
MRR over time
</span>
<SimpleChart
className="mt-1 flex-1"
height={36}
@@ -39,29 +41,29 @@ export function RevenueIllustration() {
{/* Revenue by referrer */}
<div className="flex-1 overflow-hidden rounded-xl border bg-card">
<div className="row border-b border-border px-3 py-1.5">
<span className="flex-1 text-[8px] uppercase tracking-wider text-muted-foreground">
<div className="row border-border border-b px-3 py-1.5">
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
Referrer
</span>
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Revenue
</span>
</div>
{referrers.map((r) => (
<div
className="row items-center gap-2 border-b border-border/50 px-3 py-1.5 last:border-0"
className="row items-center gap-2 border-border/50 border-b px-3 py-1.5 last:border-0"
key={r.name}
>
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
<span className="w-20 flex-none truncate text-[9px] text-muted-foreground">
{r.name}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-1 rounded-full bg-emerald-500/70"
style={{ width: `${r.pct}%` }}
/>
</div>
<span className="font-mono text-[9px] text-emerald-500 flex-none">
<span className="flex-none font-mono text-[9px] text-emerald-500">
{r.amount}
</span>
</div>

View File

@@ -2,10 +2,10 @@ import { PlayIcon } from 'lucide-react';
export function SessionReplayIllustration() {
return (
<div className="h-full px-6 pb-3 pt-4">
<div className="h-full px-6 pt-4 pb-3">
<div className="col h-full overflow-hidden rounded-xl border border-border bg-background shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
{/* Browser chrome */}
<div className="row shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2">
<div className="row shrink-0 items-center gap-1.5 border-border border-b bg-muted/30 px-3 py-2">
<div className="h-2 w-2 rounded-full bg-red-400" />
<div className="h-2 w-2 rounded-full bg-yellow-400" />
<div className="h-2 w-2 rounded-full bg-green-400" />
@@ -26,16 +26,10 @@ export function SessionReplayIllustration() {
<div className="h-2 w-24 rounded-full bg-muted/20" />
{/* Click heatspot */}
<div
className="absolute"
style={{ left: '62%', top: '48%' }}
>
<div className="absolute" style={{ left: '62%', top: '48%' }}>
<div className="h-4 w-4 animate-pulse rounded-full border-2 border-blue-500/70 bg-blue-500/20" />
</div>
<div
className="absolute"
style={{ left: '25%', top: '32%' }}
>
<div className="absolute" style={{ left: '25%', top: '32%' }}>
<div className="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
</div>
@@ -71,11 +65,11 @@ export function SessionReplayIllustration() {
</div>
{/* Playback bar */}
<div className="row shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2">
<div className="row shrink-0 items-center gap-2 border-border border-t bg-muted/20 px-3 py-2">
<PlayIcon className="size-3 shrink-0 text-muted-foreground" />
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-muted">
<div className="relative h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
className="absolute top-0 left-0 h-1 rounded-full bg-blue-500"
style={{ width: '42%' }}
/>
</div>

View File

@@ -9,9 +9,21 @@ const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const STATS = [
{ label: 'Visitors', value: 4128, formatted: null, change: 12, up: true },
{ label: 'Page views', value: 12438, formatted: '12.4k', change: 8, up: true },
{
label: 'Page views',
value: 12_438,
formatted: '12.4k',
change: 8,
up: true,
},
{ label: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
{
label: 'Avg. session',
value: null,
formatted: '3m 23s',
change: 5,
up: true,
},
];
const SOURCES = [
@@ -38,7 +50,9 @@ function AreaChart({ data }: { data: number[] }) {
const h = 64;
const xStep = w / (data.length - 1);
const pts = data.map((v, i) => ({ x: i * xStep, y: h - (v / max) * h }));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
const line = pts
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`)
.join(' ');
const area = `${line} L ${w},${h} L 0,${h} Z`;
const last = pts[pts.length - 1];
@@ -87,7 +101,7 @@ export function WebAnalyticsIllustration() {
}, []);
return (
<div className="aspect-video col gap-2.5 p-5">
<div className="col aspect-video gap-2.5 p-5">
{/* Header */}
<div className="row items-center justify-between">
<div className="row items-center gap-1.5">
@@ -95,7 +109,7 @@ export function WebAnalyticsIllustration() {
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="text-[10px] font-medium text-muted-foreground">
<span className="font-medium text-[10px] text-muted-foreground">
<NumberFlow value={liveVisitors} /> online now
</span>
</div>
@@ -111,7 +125,9 @@ export function WebAnalyticsIllustration() {
className="col gap-0.5 rounded-lg border bg-card px-2 py-1.5"
key={stat.label}
>
<span className="text-[8px] text-muted-foreground">{stat.label}</span>
<span className="text-[8px] text-muted-foreground">
{stat.label}
</span>
<span className="font-mono font-semibold text-xs leading-tight">
{stat.formatted ??
(stat.value !== null ? (
@@ -128,8 +144,10 @@ export function WebAnalyticsIllustration() {
</div>
{/* Area chart */}
<div className="flex-1 col gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
<span className="text-[8px] text-muted-foreground">Unique visitors</span>
<div className="col flex-1 gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
<span className="text-[8px] text-muted-foreground">
Unique visitors
</span>
<AreaChart data={VISITOR_DATA} />
<div className="row justify-between px-0.5">
{DAYS.map((d) => (

View File

@@ -1,7 +1,7 @@
// Thank you: https://ui.aceternity.com/components/infinite-moving-cards
import { cn } from '@/lib/utils';
import React, { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
export const InfiniteMovingCards = <T,>({
items,
@@ -47,12 +47,12 @@ export const InfiniteMovingCards = <T,>({
if (direction === 'left') {
containerRef.current.style.setProperty(
'--animation-direction',
'forwards',
'forwards'
);
} else {
containerRef.current.style.setProperty(
'--animation-direction',
'reverse',
'reverse'
);
}
}
@@ -71,23 +71,23 @@ export const InfiniteMovingCards = <T,>({
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,
'scroller relative z-20 -ml-4 w-screen overflow-hidden md:-ml-[1200px] md:w-[calc(100vw+1400px)]',
className
)}
ref={containerRef}
>
<ul
ref={scrollerRef}
className={cn(
'flex min-w-full shrink-0 gap-8 py-4 w-max flex-nowrap items-start',
'flex w-max min-w-full shrink-0 flex-nowrap items-start gap-8 py-4',
start && 'animate-scroll',
pauseOnHover && 'hover:[animation-play-state:paused]',
pauseOnHover && 'hover:[animation-play-state:paused]'
)}
ref={scrollerRef}
>
{items.map((item, idx) => (
<li
className="w-[310px] max-w-full relative shrink-0 md:w-[400px]"
className="relative w-[310px] max-w-full shrink-0 md:w-[400px]"
key={idx.toString()}
>
{renderItem(item, idx)}

View File

@@ -3,29 +3,29 @@ import { cn } from '@/lib/utils';
export function Logo({ className }: { className?: string }) {
return (
<svg
className={cn('w-16 text-black dark:text-white', className)}
fill="currentColor"
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"
width="10.3474"
x="34.0269"
y="0.368164"
/>
<rect
x="49.9458"
y="0.368164"
width="10.3474"
height="17.5109"
rx="5.17372"
width="10.3474"
x="49.9458"
y="0.368164"
/>
<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"
fillRule="evenodd"
/>
</svg>
);

View File

@@ -1,18 +1,21 @@
import type { LucideIcon } from 'lucide-react';
import type React from 'react';
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
export function Perks({
perks,
className,
}: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
}: {
perks: { text: string; icon: PerkIcon }[];
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" />
<li className="text-muted-foreground text-sm" key={perk.text}>
<perk.icon className="relative -top-px mr-2 inline-block size-4" />
{perk.text}
</li>
))}

View File

@@ -1,10 +1,9 @@
'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';
import { cn } from '@/lib/utils';
export function PricingSlider() {
const [index, setIndex] = useState(2);
@@ -14,15 +13,15 @@ export function PricingSlider() {
return (
<>
<Slider
value={[index]}
max={PRICING.length}
onValueChange={(value) => setIndex(value[0])}
step={1}
tooltip={
match
? `${formatNumber(match.events)} events per month`
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
}
onValueChange={(value) => setIndex(value[0])}
value={[index]}
/>
{match ? (
@@ -30,7 +29,6 @@ export function PricingSlider() {
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
@@ -38,13 +36,14 @@ export function PricingSlider() {
maximumFractionDigits: 1,
}}
locales={'en-US'}
value={match.price}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
<span className="ml-2 text-muted-foreground text-sm">/ month</span>
</div>
<span
className={cn(
'text-sm text-muted-foreground italic opacity-100',
match.price === 0 && 'opacity-0',
'text-muted-foreground text-sm italic opacity-100',
match.price === 0 && 'opacity-0'
)}
>
+ VAT if applicable

View File

@@ -20,8 +20,7 @@ export function ScrollTracker() {
const scrollTop = window.scrollY;
const docHeight =
document.documentElement.scrollHeight - window.innerHeight;
const percent =
docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
const percent = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
if (percent >= 50) {
hasFired.current = true;

View File

@@ -11,7 +11,7 @@ export function Section({
id?: string;
}) {
return (
<section id={id} className={cn('my-32 col', className)} {...props}>
<section className={cn('col my-32', className)} id={id} {...props}>
{children}
</section>
);
@@ -47,7 +47,7 @@ export function SectionHeader({
align === 'center'
? 'center-center text-center'
: 'items-start text-left',
className,
className
)}
>
{label && <SectionLabel>{label}</SectionLabel>}
@@ -55,7 +55,7 @@ export function SectionHeader({
{title}
</Heading>
{description && (
<p className={cn('text-muted-foreground max-w-3xl')}>{description}</p>
<p className={cn('max-w-3xl text-muted-foreground')}>{description}</p>
)}
</div>
);
@@ -71,8 +71,8 @@ export function SectionLabel({
return (
<span
className={cn(
'text-xs uppercase tracking-wider text-muted-foreground font-medium',
className,
'font-medium text-muted-foreground text-xs uppercase tracking-wider',
className
)}
>
{children}

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
interface SimpleChartProps {
width?: number;
height?: number;
@@ -18,7 +16,9 @@ export function SimpleChart({
className,
}: SimpleChartProps) {
// Skip if no points
if (!points.length) return null;
if (!points.length) {
return null;
}
// Calculate scaling factors
const maxValue = Math.max(...points);
@@ -45,8 +45,8 @@ export function SimpleChart({
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={`w-full ${className ?? ''}`}
viewBox={`0 0 ${width} ${height}`}
>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">

View File

@@ -1,20 +1,20 @@
import { cva, type VariantProps } from 'class-variance-authority';
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',
'center-center h-7 gap-2 self-auto rounded-full border px-4 text-xs shadow-sm',
{
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',
'bg-background-light text-muted-foreground dark:bg-background-dark',
dark: 'border-background/10 bg-foreground-light text-muted shadow-background/5 dark:bg-foreground-dark',
},
},
defaultVariants: {
variant: 'light',
},
},
}
);
interface TagProps

View File

@@ -10,20 +10,20 @@ interface Props {
export const Toc: React.FC<Props> = ({ toc }) => {
return (
<FeatureCardContainer className="gap-2">
<span className="text-lg font-semibold">Table of contents</span>
<span className="font-semibold text-lg">Table of contents</span>
<ul>
{toc.map((item) => (
<li
key={item.url}
className="py-1"
key={item.url}
style={{ marginLeft: `${(item.depth - 2) * (4 * 4)}px` }}
>
<Link
className="row group/toc-item items-center gap-2 hover:underline"
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" />
<ArrowRightIcon className="h-4 w-4 shrink-0 opacity-30 transition-opacity group-hover/toc-item:opacity-100" />
<span className="truncate text-sm">{item.title}</span>
</Link>
</li>

View File

@@ -1,6 +1,5 @@
import {
BadgeIcon,
CheckCheckIcon,
CheckIcon,
HeartIcon,
MessageCircleIcon,
@@ -36,7 +35,7 @@ export function TwitterCard({
if (Array.isArray(content) && typeof content[0] === 'string') {
return content.map((line) => (
<p key={line} className="text-sm">
<p className="text-sm" key={line}>
{line}
</p>
));
@@ -46,11 +45,11 @@ export function TwitterCard({
};
return (
<div className="border rounded-3xl p-8 col gap-4 bg-background-light">
<div className="col gap-4 rounded-3xl border bg-background-light p-8">
<div className="row gap-4">
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
<div className="size-12 shrink-0 overflow-hidden rounded-full bg-muted">
{avatarUrl && (
<Image src={avatarUrl} alt={name} width={48} height={48} />
<Image alt={name} height={48} src={avatarUrl} width={48} />
)}
</div>
<div className="col gap-4">
@@ -58,9 +57,9 @@ export function TwitterCard({
<div className="">
<span className="font-medium">{name}</span>
{verified && (
<div className="relative inline-block top-0.5 ml-1">
<div className="relative top-0.5 ml-1 inline-block">
<BadgeIcon className="size-4 fill-[#1D9BF0] text-[#1D9BF0]" />
<div className="absolute inset-0 center-center">
<div className="center-center absolute inset-0">
<CheckIcon className="size-2 text-white" strokeWidth={3} />
</div>
</div>
@@ -73,15 +72,15 @@ export function TwitterCard({
{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" />
<MessageCircleIcon className="size-4 fill-background transition-all 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" />
<RefreshCwIcon className="size-4 fill-background transition-all 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" />
<HeartIcon className="size-4 fill-background transition-all hover:fill-rose-500 hover:text-rose-500" />
{/* <span>{likes}</span> */}
</div>
</div>

View File

@@ -1,9 +1,8 @@
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';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
@@ -15,8 +14,8 @@ const AccordionItem = ({
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item>>;
}) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b last:border-b-0', className)}
ref={ref}
{...props}
/>
);
@@ -30,13 +29,13 @@ const AccordionTrigger = ({
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Trigger>>;
}) => (
<AccordionPrimitive.Header className="flex not-prose">
<AccordionPrimitive.Header className="not-prose flex">
<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,
'group relative flex flex-1 cursor-pointer items-center justify-between overflow-hidden py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180',
className
)}
ref={ref}
{...props}
>
<FeatureCardBackground />
@@ -56,14 +55,14 @@ const AccordionContent = ({
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Content>>;
}) => (
<AccordionPrimitive.Content
className="overflow-hidden text-muted-foreground transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
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,
'pt-0 pb-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
>
{children}

View File

@@ -1,11 +1,10 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import { cva, type VariantProps } 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',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-all hover:-translate-y-px 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: {
@@ -19,7 +18,7 @@ const buttonVariants = cva(
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',
'!px-0 !py-0 border-none bg-transparent shadow-none ring-0 hover:bg-transparent',
},
size: {
default: 'h-8 px-4',
@@ -32,7 +31,7 @@ const buttonVariants = cva(
variant: 'default',
size: 'default',
},
},
}
);
export interface ButtonProps

View File

@@ -1,10 +1,9 @@
import { type VariantProps, cva } from 'class-variance-authority';
import { cva, type VariantProps } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const inputVariants = cva(
'flex w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
'flex w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {
@@ -16,7 +15,7 @@ const inputVariants = cva(
defaultVariants: {
size: 'default',
},
},
}
);
export interface InputProps
@@ -28,9 +27,9 @@ export interface InputProps
const Input = ({ className, type, size, ref, ...props }: InputProps) => {
return (
<input
type={type}
className={cn(inputVariants({ size, className }))}
ref={ref}
type={type}
{...props}
/>
);

View File

@@ -1,8 +1,7 @@
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
import { cn } from '@/lib/utils';
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false);
@@ -31,28 +30,28 @@ const Slider = ({
return (
<>
{isMobile && (
<div className="text-sm text-muted-foreground mb-4">{tooltip}</div>
<div className="mb-4 text-muted-foreground text-sm">{tooltip}</div>
)}
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
className
)}
ref={ref}
{...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>
<Tooltip disableHoverableContent open>
<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
className="rounded-full border-white/30 bg-black py-1 text-white/70 text-xs"
side="top"
sideOffset={10}
className="rounded-full bg-black text-white/70 py-1 text-xs border-white/30"
>
{tooltip}
</TooltipContent>

View File

@@ -1,6 +1,5 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
@@ -20,12 +19,12 @@ const TooltipContent = ({
ref?: React.RefObject<React.ElementRef<typeof TooltipPrimitive.Content>>;
}) => (
<TooltipPrimitive.Content
className={cn(
'fade-in-0 zoom-in-95 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 z-50 animate-in overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-popover-foreground text-sm shadow-md data-[state=closed]:animate-out',
className
)}
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}
/>
);

View File

@@ -1,6 +1,6 @@
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { FeatureCardContainer } from './feature-card';
import { cn } from '@/lib/utils';
interface WindowImageProps {
src?: string;
@@ -24,20 +24,20 @@ export function WindowImage({
const darkSrc = srcDark || src;
const lightSrc = srcLight || src;
if (!darkSrc || !lightSrc) {
if (!(darkSrc && lightSrc)) {
throw new Error(
'WindowImage requires either src or both srcDark and srcLight',
'WindowImage requires either src or both srcDark and srcLight'
);
}
return (
<FeatureCardContainer
className={cn([
'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',
'relative z-10 overflow-hidden rounded-lg border border-border bg-foreground/10 p-4 shadow-lg/5 md:p-16 [@media(min-width:1100px)]:-mx-16',
className,
])}
>
<div className="rounded-lg overflow-hidden p-2 bg-card/80 border col gap-2 relative">
<div className="col relative gap-2 overflow-hidden rounded-lg border bg-card/80 p-2">
{/* Window controls */}
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
@@ -46,25 +46,25 @@ export function WindowImage({
<div className="size-2 rounded-full bg-green-500" />
</div>
</div>
<div className="relative w-full border rounded-md overflow-hidden">
<div className="relative w-full overflow-hidden rounded-md border">
<Image
src={darkSrc}
alt={alt}
width={1200}
className="hidden h-auto w-full dark:block"
height={800}
className="hidden dark:block w-full h-auto"
src={darkSrc}
width={1200}
/>
<Image
src={lightSrc}
alt={alt}
width={1200}
className="h-auto w-full dark:hidden"
height={800}
className="dark:hidden w-full h-auto"
src={lightSrc}
width={1200}
/>
</div>
</div>
{caption && (
<figcaption className="text-center text-sm text-muted-foreground max-w-lg mx-auto">
<figcaption className="mx-auto max-w-lg text-center text-muted-foreground text-sm">
{caption}
</figcaption>
)}

View File

@@ -1,4 +1,4 @@
import { readFileSync, readdirSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface CompareSeo {
@@ -203,7 +203,7 @@ export interface CompareData {
const contentDir = join(process.cwd(), 'content', 'compare');
export async function getCompareData(
slug: string,
slug: string
): Promise<CompareData | null> {
try {
const filePath = join(contentDir, `${slug}.json`);

View File

@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react';
export function useIsDarkMode() {
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof window === 'undefined') return false;
if (typeof window === 'undefined') {
return false;
}
// Check localStorage first
const savedTheme = window.localStorage.getItem('theme');

View File

@@ -1,4 +1,4 @@
import { readFileSync, readdirSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface FeatureSeo {
@@ -103,7 +103,7 @@ export interface FeatureData {
const contentDir = join(process.cwd(), 'content', 'features');
export async function getFeatureData(
slug: string,
slug: string
): Promise<FeatureData | null> {
try {
const filePath = join(contentDir, `${slug}.json`);
@@ -136,7 +136,9 @@ export async function loadFeatureSource(): Promise<FeatureData[]> {
const results: FeatureData[] = [];
for (const slug of slugs) {
const data = await getFeatureData(slug);
if (data) results.push(data);
if (data) {
results.push(data);
}
}
return results;
}
@@ -155,7 +157,9 @@ export function loadFeatureSourceSync(): FeatureData[] {
return { ...data, url: `/features/${slug}` };
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
console.error('Error loading feature source:', error);
return [];
}

View File

@@ -1,7 +1,7 @@
export async function getGithubRepoInfo() {
try {
const res = await fetch(
'https://api.github.com/repos/Openpanel-dev/openpanel',
'https://api.github.com/repos/Openpanel-dev/openpanel'
);
return res.json();
} catch (e) {

View File

@@ -1,9 +1,6 @@
import type { Metadata } from 'next';
import {
OPENPANEL_DESCRIPTION,
OPENPANEL_SITE_NAME,
} from './openpanel-brand';
import { url as baseUrl } from './layout.shared';
import { OPENPANEL_DESCRIPTION, OPENPANEL_SITE_NAME } from './openpanel-brand';
const siteName = OPENPANEL_SITE_NAME;
const defaultDescription = OPENPANEL_DESCRIPTION;
@@ -48,7 +45,7 @@ export function getRawMetadata(
description,
image,
}: { url: string; title: string; description: string; image: string },
meta: Metadata = {},
meta: Metadata = {}
): Metadata {
return {
title,
@@ -64,7 +61,7 @@ export function getRawMetadata(
openGraph: {
title,
description,
siteName: siteName,
siteName,
url: baseUrl(url),
type: 'website',
images: [

View File

@@ -10,10 +10,10 @@ import {
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import { OPENPANEL_BASE_URL } from './openpanel-brand';
import type { CompareData } from './compare';
import type { FeatureData } from './features';
import { loadFeatureSourceSync } from './features';
import { OPENPANEL_BASE_URL } from './openpanel-brand';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({

Some files were not shown because too many files have changed in this diff Show More