chore:little fixes and formating and linting and patches
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -23,7 +23,7 @@ export const GET = function POST(req: Request) {
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
{} as Record<string, string>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'), {
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
GlobeIcon,
|
||||
Link2Icon,
|
||||
QrCodeIcon,
|
||||
SearchIcon,
|
||||
TimerIcon,
|
||||
} from 'lucide-react';
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
export const TOOLS = [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}%` : '—'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user