public: new page and copy improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-17 00:16:08 +01:00
parent 0ebe2768be
commit e3faab7588
22 changed files with 1026 additions and 352 deletions

View File

@@ -1,5 +1,3 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import {
BarChart3Icon,
ChevronRightIcon,
@@ -7,8 +5,10 @@ import {
GlobeIcon,
} from 'lucide-react';
import Link from 'next/link';
import { ProductAnalyticsIllustration } from './illustrations/product-analytics';
import { WebAnalyticsIllustration } from './illustrations/web-analytics';
import { FeatureCard } from '@/components/feature-card';
import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics';
import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics';
import { Section, SectionHeader } from '@/components/section';
const features = [
{
@@ -47,42 +47,42 @@ export function AnalyticsInsights() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking."
label="ANALYTICS & INSIGHTS"
title="See the full picture of your users and product performance"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking."
className="mb-16"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard
variant="large"
title="Web Analytics"
className="px-0 **:data-content:px-6"
description="Understand your website performance with privacy-first analytics and clear, actionable insights."
illustration={<WebAnalyticsIllustration />}
className="px-0 **:data-content:px-6"
title="Web Analytics"
variant="large"
/>
<FeatureCard
variant="large"
title="Product Analytics"
className="px-0 **:data-content:px-6"
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends."
illustration={<ProductAnalyticsIllustration />}
className="px-0 **:data-content:px-6"
title="Product Analytics"
variant="large"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
key={feature.title}
link={feature.link}
title={feature.title}
/>
))}
</div>
<p className="mt-8 text-center">
<Link
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"
href="/features"
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
Explore all features
<ChevronRightIcon className="size-3.5" />

View File

@@ -1,8 +1,11 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { BoltIcon, GithubIcon, ServerIcon } from 'lucide-react';
import { DataOwnershipIllustration } from './illustrations/data-ownership';
import { PrivacyIllustration } from './illustrations/privacy';
import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card';
import { GetStartedButton } from '@/components/get-started-button';
import { DataOwnershipIllustration } from '@/components/illustrations/data-ownership';
import { PrivacyIllustration } from '@/components/illustrations/privacy';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
const secondaryFeatures = [
{
@@ -29,6 +32,7 @@ export function DataPrivacy() {
return (
<Section className="container">
<SectionHeader
description="OpenPanel gives you analytics on your terms - privacy-friendly, open-source, and fully self-hostable. Every part of the platform is designed to put you in control of your data while delivering fast, reliable insights without compromising user trust."
title={
<>
Built for Control,
@@ -36,32 +40,37 @@ export function DataPrivacy() {
Transparency & Trust
</>
}
description="OpenPanel gives you analytics on your terms - privacy-friendly, open-source, and fully self-hostable. Every part of the platform is designed to put you in control of your data while delivering fast, reliable insights without compromising user trust."
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6 mt-16">
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard
variant="large"
title="Privacy-first"
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust."
illustration={<PrivacyIllustration />}
title="Privacy-first"
variant="large"
/>
<FeatureCard
variant="large"
title="Data Ownership"
description="You own your data - no vendors, no sharing, no hidden processing. Store analytics on your own infrastructure and stay in full control."
illustration={<DataOwnershipIllustration />}
title="Data Ownership"
variant="large"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{secondaryFeatures.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
key={feature.title}
title={feature.title}
/>
))}
</div>
<div className="row mt-8 gap-4">
<GetStartedButton />
<Button asChild className="px-6" size="lg" variant="outline">
<Link href="/docs/self-hosting/self-hosting">Self-host for free</Link>
</Button>
</div>
</Section>
);
}

View File

@@ -1,4 +1,5 @@
import { FaqItem, Faqs } from '@/components/faq';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
const faqData = [
@@ -63,11 +64,13 @@ export function Faq() {
return (
<Section className="container">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SectionHeader
className="mb-16"
description="Some of the most common questions we get asked."
title="FAQ"
/>
<div className="col gap-8">
<SectionHeader
description="Some of the most common questions we get asked."
title="FAQ"
/>
<GetStartedButton className="w-fit max-md:hidden" />
</div>
<Faqs>
{faqData.map((faq) => (
<FaqItem key={faq.question} question={faq.question}>

View File

@@ -1,26 +1,22 @@
'use client';
import { Competition } from '@/components/competition';
import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks';
import { Tag } from '@/components/tag';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import {
ArrowRightIcon,
CalendarIcon,
ChevronRightIcon,
CookieIcon,
CreditCardIcon,
DatabaseIcon,
FlaskRoundIcon,
GithubIcon,
ServerIcon,
StarIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { Competition } from '@/components/competition';
import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon },
@@ -40,46 +36,46 @@ function HeroImage({ className }: { className?: string }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9, x: 0 }}
animate={
isLoaded
? { opacity: 0.5, scale: 1, x: 0 }
: { opacity: 0, scale: 0.9, x: 0 }
}
transition={{
duration: 2,
}}
className={cn('absolute', className)}
initial={{ opacity: 0, scale: 0.9, x: 0 }}
style={{
left: `calc(50% - ${width / 2}px - 50px)`,
top: -270,
}}
transition={{
duration: 2,
}}
>
<Image
src="/hero-dark.webp"
alt="Hero"
width={width}
height={height}
className="hidden dark:block"
height={height}
onLoad={() => setIsLoaded(true)}
src="/hero-dark.webp"
style={{
width,
minWidth: width,
height,
}}
onLoad={() => setIsLoaded(true)}
width={width}
/>
<Image
src="/hero-light.webp"
alt="Hero"
width={width}
height={height}
className="dark:hidden"
height={height}
onLoad={() => setIsLoaded(true)}
src="/hero-light.webp"
style={{
width,
minWidth: width,
height,
}}
onLoad={() => setIsLoaded(true)}
width={width}
/>
</motion.div>
);
@@ -88,12 +84,12 @@ function HeroImage({ className }: { className?: string }) {
export function Hero() {
return (
<HeroContainer className="-mb-32 max-sm:**:data-children:pb-0">
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
<div className="col w-full gap-8 sm:w-1/2 sm:pr-12">
<div className="col gap-4">
{/* <div className="font-mono text-sm text-muted-foreground">
TRUSTED BY 1,000+ COMPANIES • 4.7K GITHUB STARS
</div> */}
<h1 className="text-4xl md:text-5xl font-semibold leading-[1.1]">
<div className="font-mono text-muted-foreground text-sm">
TRUSTED BY 1,000+ PROJECTS
</div>
<h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl">
OpenPanel - The open-source alternative to <Competition />
</h1>
<p className="text-lg text-muted-foreground">
@@ -104,7 +100,7 @@ export function Hero() {
</div>
<div className="row gap-4">
<GetStartedButton />
<Button size="lg" variant="outline" asChild className="px-6">
<Button asChild className="px-6" size="lg" variant="outline">
<Link
href="https://demo.openpanel.dev/demo/shoey"
rel="noreferrer noopener nofollow"
@@ -118,39 +114,39 @@ export function Hero() {
<Perks perks={perks} />
</div>
<div className="col sm:w-1/2 relative group max-sm:px-4">
<div className="col group relative max-sm:px-4 sm:w-1/2">
<div
className={cn([
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-64',
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
'sm:absolute sm:-top-12 sm:-bottom-64 sm:left-0 sm:w-[800px]',
'relative max-sm:-mx-4 max-sm:mt-12 max-sm:h-[800px]',
])}
>
{/* Window controls */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
<div className="flex h-12 items-center gap-2 border-border border-b bg-muted/50 px-4 py-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-yellow-500" />
<div className="h-3 w-3 rounded-full bg-green-500" />
</div>
{/* URL bar */}
<a
target="_blank"
rel="noreferrer noopener nofollow"
className="group mx-4 flex flex-1 items-center gap-2 rounded-md border border-border bg-background/20 px-3 py-1 text-sm"
href="https://demo.openpanel.dev/demo/shoey"
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background/20 rounded-md border border-border flex items-center gap-2"
rel="noreferrer noopener nofollow"
target="_blank"
>
<span className="text-muted-foreground flex-1">
<span className="flex-1 text-muted-foreground">
https://demo.openpanel.dev
</span>
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
<ArrowRightIcon className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
</a>
</div>
<iframe
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
className="w-full h-full"
title="Live preview"
className="h-full w-full"
scrolling="no"
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
title="Live preview"
/>
</div>
</div>
@@ -175,13 +171,13 @@ export function HeroContainer({
<HeroImage />
</div>
<div
className="container relative col sm:row py-44 max-sm:pt-32"
className="col sm:row container relative py-44 max-sm:pt-32"
data-children
>
{children}
</div>
{divider && (
<div className="absolute bottom-0 left-0 right-0 h-20 border-t border-border rounded-t-[3rem] md:rounded-t-[6rem] bg-background shadow-[0_0_100px_var(--background)]" />
<div className="absolute right-0 bottom-0 left-0 h-20 rounded-t-[3rem] border-border border-t bg-background shadow-[0_0_100px_var(--background)] md:rounded-t-[6rem]" />
)}
</section>
);

View File

@@ -1,113 +0,0 @@
import React from 'react';
type IllustrationProps = {
className?: string;
};
export function DataOwnershipIllustration({
className = '',
}: IllustrationProps) {
return (
<div>
{/* Main layout */}
<div className="relative grid aspect-2/1 grid-cols-5 gap-3">
{/* Left: your server card */}
<div
className="
col-span-3 rounded-2xl border border-border bg-card/80
p-3 sm:p-4 shadow-xl backdrop-blur
transition-all duration-300
group-hover:-translate-y-1 group-hover:-translate-x-0.5
"
>
<div className="flex items-center justify-between text-xs text-foreground">
<span>Your server</span>
<span className="flex items-center gap-1 rounded-full bg-card/80 px-2 py-0.5 text-[10px] text-blue-300">
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
In control
</span>
</div>
{/* "Server" visual */}
<div className="mt-3 space-y-2">
<div className="flex gap-1.5">
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
<p className="text-[10px] text-muted-foreground">Region</p>
<p className="text-xs font-medium text-foreground">
EU / Custom
</p>
</div>
<div className="flex-1 rounded-xl bg-card/80 border border-border px-3 py-2">
<p className="text-[10px] text-muted-foreground">Retention</p>
<p className="text-xs font-medium text-foreground">
Configurable
</p>
</div>
</div>
{/* mini "database"/requests strip */}
<div className="mt-1 rounded-xl border border-border bg-card/90 px-3 py-2 text-[11px] text-foreground">
<div className="flex items-center justify-between">
<span>Events stored</span>
<span className="text-[10px] text-muted-foreground">
locally
</span>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-1.5 flex-1 rounded-full bg-blue-400/70" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/40" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/20" />
</div>
</div>
<div className="mt-1 rounded-xl border border-border bg-card/90 px-3 py-2 text-[11px] text-foreground">
<div className="flex items-center justify-between">
<span>CPU</span>
<span className="text-[10px] text-muted-foreground">20%</span>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-1.5 flex-1 rounded-full bg-blue-400/70" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/40" />
<div className="h-1.5 flex-1 rounded-full bg-blue-400/20" />
</div>
</div>
</div>
</div>
{/* Right: third-party contrast */}
<div
className="
col-span-2 rounded-2xl border border-border/80 bg-card/40
p-3 text-[11px] text-muted-foreground
transition-all duration-300
group-hover:translate-y-1 group-hover:translate-x-0.5 group-hover:opacity-70
"
>
<p className="text-xs text-muted-foreground mb-2">or use our cloud</p>
<ul className="space-y-1.5">
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
Zero server setup
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
Auto-scaling & backups
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
99.9% uptime
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
24/7 support
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-1 rounded-full bg-blue-400" />
Export data anytime
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import React from 'react';
type IllustrationProps = {
className?: string;
};
export function PrivacyIllustration({ className = '' }: IllustrationProps) {
return (
<div>
{/* Floating cards */}
<div className="relative aspect-3/2 md:aspect-2/1">
{/* Back card */}
<div
className="
absolute top-0 left-0 right-10 bottom-10 rounded-2xl border border-border/80 bg-card/70
backdrop-blur-sm shadow-lg
transition-all duration-300
group-hover:-translate-y-1 group-hover:-rotate-2
"
>
<div className="flex items-center justify-between px-4 pt-3 text-xs text-muted-foreground">
<span>Session duration</span>
<span className="flex items-center gap-1">
3m 12s
<span className="text-[10px] text-blue-400">+8%</span>
</span>
</div>
{/* Simple line chart */}
<div className="mt-3 px-4">
<svg
viewBox="0 0 120 40"
className="h-16 w-full text-muted-foreground"
>
<path
d="M2 32 L22 18 L40 24 L60 10 L78 16 L96 8 L118 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="opacity-60"
/>
<circle cx="118" cy="14" r="2.5" className="fill-blue-400" />
</svg>
</div>
</div>
{/* Front card */}
<div
className="
col
absolute top-10 left-4 right-0 bottom-0 rounded-2xl border border-border/80
bg-card shadow-xl
transition-all duration-300
group-hover:translate-y-1 group-hover:rotate-2
"
>
<div className="flex items-center justify-between px-4 pt-3 text-xs text-foreground">
<span>Anonymous visitors</span>
<span className="text-[10px] rounded-full bg-card px-2 py-0.5 text-muted-foreground">
No cookies
</span>
</div>
<div className="flex items-end justify-between px-4 pt-4 pb-3">
<div>
<p className="text-[11px] text-muted-foreground mb-1">
Active now
</p>
<p className="text-2xl font-semibold text-foreground">128</p>
</div>
<div className="space-y-1.5 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span>IP + UA hashed daily</span>
</div>
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span>No fingerprinting</span>
</div>
</div>
</div>
{/* "Sources" row */}
<div className="mt-auto flex gap-2 border-t border-border px-3 py-2.5 text-[11px]">
<div className="flex-1 rounded-xl bg-card/90 px-3 py-1.5 flex items-center justify-between">
<span className="text-muted-foreground">Direct</span>
<span className="text-foreground">42%</span>
</div>
<div className="flex-1 rounded-xl bg-card/90 px-3 py-1.5 flex items-center justify-between">
<span className="text-muted-foreground">Organic</span>
<span className="text-foreground">58%</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
'use client';
import { cn } from '@/lib/utils';
import { ResponsiveFunnel } from '@nivo/funnel';
import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion, useSpring } from 'framer-motion';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
function useFunnelSteps() {
const { resolvedTheme } = useTheme();
return [
{
id: 'Visitors',
label: 'Visitors',
value: 10000,
percentage: 100,
color: resolvedTheme === 'dark' ? '#333' : '#888',
},
{
id: 'Add to cart',
label: 'Add to cart',
value: 7000,
percentage: 32,
color: resolvedTheme === 'dark' ? '#222' : '#999',
},
{
id: 'Checkout',
label: 'Checkout',
value: 5000,
percentage: 8.9,
color: resolvedTheme === 'dark' ? '#111' : '#e1e1e1',
},
];
}
export function ProductAnalyticsIllustration() {
return (
<div className="aspect-video">
<FunnelVisualization />
</div>
);
}
export const PartLabel = ({ part }: { part: any }) => {
const { resolvedTheme } = useTheme();
return (
<g transform={`translate(${part.x}, ${part.y})`}>
<text
textAnchor="middle"
dominantBaseline="central"
style={{
fill: resolvedTheme === 'dark' ? '#fff' : '#000',
pointerEvents: 'none',
fontSize: 12,
fontWeight: 500,
}}
>
{part.data.label}
</text>
</g>
);
};
function Labels(props: any) {
return props.parts.map((part: any) => (
<PartLabel key={part.data.id} part={part} />
));
}
function FunnelVisualization() {
const funnelSteps = useFunnelSteps();
const colors = funnelSteps.map((stage) => stage.color);
const nivoData = funnelSteps.map((stage) => ({
id: stage.id,
value: stage.value,
label: stage.label,
}));
return (
<div className="w-full h-full">
<ResponsiveFunnel
data={nivoData}
margin={{ top: 20, right: 0, bottom: 20, left: 0 }}
direction="horizontal"
shapeBlending={0.6}
colors={colors}
enableBeforeSeparators={false}
enableAfterSeparators={false}
beforeSeparatorLength={0}
afterSeparatorLength={0}
afterSeparatorOffset={0}
beforeSeparatorOffset={0}
currentPartSizeExtension={5}
borderWidth={20}
currentBorderWidth={15}
tooltip={() => null}
layers={['parts', Labels]}
/>
</div>
);
}

View File

@@ -1,189 +0,0 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
name: 'Google',
percentage: 49,
value: 2039,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
name: 'Instagram',
percentage: 23,
value: 920,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
name: 'Facebook',
percentage: 18,
value: 750,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter',
percentage: 10,
value: 412,
},
];
const COUNTRIES = [
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
];
export function WebAnalyticsIllustration() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="px-12 group aspect-video">
<div className="relative h-full col">
<MetricCard
title="Session duration"
value="3m 23s"
change="3%"
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
/>
<MetricCard
title="Bounce rate"
value="46%"
change="3%"
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
/>
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
<BarCell
{...TRAFFIC_SOURCES[currentSourceIndex]}
className="group-hover:scale-105 transition-all duration-300"
/>
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
className="group-hover:scale-105 transition-all duration-300"
/>
</div>
</div>
</div>
);
}
function MetricCard({
title,
value,
change,
chartPoints,
color,
className,
}: {
title: string;
value: string;
change: string;
chartPoints: number[];
color: string;
className?: string;
}) {
return (
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
<div className="row items-end justify-between">
<div>
<div className="text-muted-foreground text-sm">{title}</div>
<div className="text-2xl font-semibold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium">
<ArrowUpIcon className="size-3" strokeWidth={3} />
<div>{change}</div>
</div>
</div>
<SimpleChart
width={400}
height={30}
points={chartPoints}
strokeColor={color}
className="mt-4"
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
className,
}: {
icon: string;
name: string;
percentage: number;
value: number;
className?: string;
}) {
return (
<div
className={cn(
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
className,
)}
>
<div
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
style={{
width: `${percentage}%`,
}}
/>
<div className="relative row justify-between ">
<div className="row gap-2 items-center font-medium text-sm">
{icon.startsWith('http') ? (
<Image
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
width={16}
height={16}
/>
) : (
<div className="text-2xl">{icon}</div>
)}
<AnimatePresence mode="popLayout">
<motion.div
key={name}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
{name}
</motion.div>
</AnimatePresence>
</div>
<div className="row gap-3 font-mono text-sm">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span>
<NumberFlow value={value} locales={'en-US'} />
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,14 @@
'use client';
import NumberFlow from '@number-flow/react';
import { PRICING } from '@openpanel/payments/prices';
import { CheckIcon, ServerIcon, StarIcon } from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react';
import { PRICING } from '@openpanel/payments/prices';
import { CheckIcon, StarIcon } from 'lucide-react';
import { useState } from 'react';
import { formatEventsCount } from '@/lib/utils';
import { cn, formatEventsCount } from '@/lib/utils';
const features = [
'Unlimited websites or apps',
@@ -28,51 +26,56 @@ export function Pricing() {
return (
<Section className="container">
<div className="col md:row gap-16">
<div className="w-full md:w-1/3 min-w-sm col gap-4 border rounded-3xl p-6 bg-linear-to-b from-card to-background">
<p className="text-sm text-muted-foreground">
<div className="col w-full min-w-sm gap-4 rounded-3xl border bg-linear-to-b from-card to-background p-6 md:w-1/3">
<p className="text-muted-foreground text-sm">
Choose how many events you'll track this month
</p>
<div className="row flex-wrap gap-2">
{PRICING.map((tier, index) => (
<Button
className={cn('relative h-8 rounded-full border px-4')}
key={tier.price}
variant={selectedIndex === index ? 'default' : 'outline'}
size="sm"
className={cn('h-8 rounded-full relative px-4 border')}
onClick={() => setSelectedIndex(index)}
size="sm"
variant={selectedIndex === index ? 'default' : 'outline'}
>
{tier.popular && <StarIcon className="size-4" />}
{formatEventsCount(tier.events)}
</Button>
))}
<Button
variant={selectedIndex === -1 ? 'default' : 'outline'}
size="sm"
className={cn('h-8 rounded-full relative px-4 border')}
className={cn('relative h-8 rounded-full border px-4')}
onClick={() => setSelectedIndex(-1)}
size="sm"
variant={selectedIndex === -1 ? 'default' : 'outline'}
>
Custom
</Button>
</div>
<div className="col items-baseline mt-8 md:mt-auto w-full">
<div className="col mt-8 w-full items-baseline md:mt-auto">
{selected ? (
<>
<NumberFlow
className="text-5xl font-bold"
value={selected.price}
format={{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}}
locales={'en-US'}
/>
<div className="row justify-between w-full">
<span className="text-muted-foreground/80 text-sm -mt-2">
<div className="row items-end gap-3">
<NumberFlow
className="font-bold text-5xl"
format={{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}}
locales={'en-US'}
value={selected.price}
/>
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
</div>
<div className="row w-full justify-between">
<span className="-mt-2 text-muted-foreground/80 text-sm">
Per month
</span>
<span className="text-muted-foreground/80 text-sm -mt-2">
<span className="-mt-2 text-muted-foreground/80 text-sm">
+ VAT if applicable
</span>
</div>
@@ -89,18 +92,18 @@ export function Pricing() {
</div>
</div>
<div className="col gap-8 justify-center flex-1 shrink-0">
<div className="col flex-1 shrink-0 justify-center gap-8">
<div className="col gap-4">
<SectionHeader
title="Simple, transparent pricing"
description="Pay only for what you use. Choose your event volume - everything else is unlimited. No surprises, no hidden fees."
title="Simple, transparent pricing"
/>
</div>
<ul className="col gap-2">
{features.map((feature) => (
<li key={feature} className="row gap-2 items-start text-sm">
<CheckIcon className="size-4 shrink-0 mt-0.5" />
<li className="row items-start gap-2 text-sm" key={feature}>
<CheckIcon className="mt-0.5 size-4 shrink-0" />
<span>{feature}</span>
</li>
))}
@@ -108,9 +111,16 @@ export function Pricing() {
<GetStartedButton className="w-fit" />
<p className="text-sm text-muted-foreground">
All features are included upfront - no hidden costs. You choose how
many events to track each month.
<p className="row items-center gap-2 text-muted-foreground text-sm">
<ServerIcon className="size-4 shrink-0" />
Prefer to self-host?{' '}
<Link
className="text-primary hover:underline"
href="/docs/self-hosting/self-hosting"
>
Deploy for free
</Link>{' '}
with unlimited events.
</p>
</div>
</div>