fix: improve landing page

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-10 22:28:14 +01:00
parent 9836f75e17
commit 1b898660ad
18 changed files with 734 additions and 234 deletions

View File

@@ -59,7 +59,7 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
## Insights ## Insights
If you have configured [Insights](/features/insights) for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured. If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
--- ---

View File

@@ -1,56 +1,38 @@
import { import { ChevronRightIcon } from 'lucide-react';
BarChart3Icon,
ChevronRightIcon,
DollarSignIcon,
GlobeIcon,
PlayCircleIcon,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card'; import { FeatureCard } from '@/components/feature-card';
import { NotificationsIllustration } from '@/components/illustrations/notifications';
import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics'; import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics';
import { RetentionIllustration } from '@/components/illustrations/retention';
import { SessionReplayIllustration } from '@/components/illustrations/session-replay';
import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics'; import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics';
import { Section, SectionHeader } from '@/components/section'; import { Section, SectionHeader } from '@/components/section';
const features = [ function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const mediumFeatures = [
{ {
title: 'Revenue tracking', title: 'Retention',
description: description:
'Track revenue from your payments and get insights into your revenue sources.', 'Know how many users come back after day 1, day 7, day 30. Identify which behaviors predict long-term retention.',
icon: DollarSignIcon, illustration: wrap(<RetentionIllustration />),
link: { link: { href: '/features/retention', children: 'View retention' },
href: '/features/revenue-tracking',
children: 'More about revenue',
},
},
{
title: 'Profiles & Sessions',
description:
'Track individual users and their complete journey across your platform.',
icon: GlobeIcon,
link: {
href: '/features/identify-users',
children: 'Identify your users',
},
},
{
title: 'Event Tracking',
description:
'Capture every important interaction with flexible event tracking.',
icon: BarChart3Icon,
link: {
href: '/features/event-tracking',
children: 'All about tracking',
},
}, },
{ {
title: 'Session Replay', title: 'Session Replay',
description: description:
'Watch real user sessions to see exactly what happened. Privacy controls built in, loads async.', 'Watch real user sessions to see exactly what happened — clicks, scrolls, rage clicks. Privacy controls built in.',
icon: PlayCircleIcon, illustration: wrap(<SessionReplayIllustration />),
link: { link: { href: '/features/session-replay', children: 'See session replay' },
href: '/features/session-replay', },
children: 'See session replay', {
}, title: 'Notifications',
description:
'Get notified when a funnel is completed. Stay on top of key moments in your product without watching dashboards all day.',
illustration: wrap(<NotificationsIllustration />),
link: { href: '/features/notifications', children: 'Set up notifications' },
}, },
]; ];
@@ -59,37 +41,39 @@ export function AnalyticsInsights() {
<Section className="container"> <Section className="container">
<SectionHeader <SectionHeader
className="mb-16" className="mb-16"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking." description="From first page view to long-term retention — every touchpoint in one platform. No sampling, no data limits, no guesswork."
label="ANALYTICS & INSIGHTS" label="ANALYTICS & INSIGHTS"
title="See the full picture of your users and product performance" title="Everything you need to understand your users"
/> />
<div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
className="px-0 **:data-content:px-6" className="px-0 **:data-content:px-6"
description="Understand your website performance with privacy-first analytics and clear, actionable insights." description="Understand your website performance with privacy-first analytics. Track visitors, referrers, and page views without touching user cookies."
illustration={<WebAnalyticsIllustration />} illustration={<WebAnalyticsIllustration />}
title="Web Analytics" title="Web Analytics"
variant="large"
/> />
<FeatureCard <FeatureCard
className="px-0 **:data-content:px-6" className="px-0 **:data-content:px-6"
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends." description="Go beyond page views. Track custom events, understand user flows, and explore exactly how people use your product."
illustration={<ProductAnalyticsIllustration />} illustration={<ProductAnalyticsIllustration />}
title="Product Analytics" title="Product Analytics"
variant="large"
/> />
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{features.map((feature) => ( <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{mediumFeatures.map((feature) => (
<FeatureCard <FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description} description={feature.description}
icon={feature.icon} illustration={feature.illustration}
key={feature.title} key={feature.title}
link={feature.link} link={feature.link}
title={feature.title} title={feature.title}
/> />
))} ))}
</div> </div>
<p className="mt-8 text-center"> <p className="mt-8 text-center">
<Link <Link
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground" className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"

View File

@@ -15,23 +15,23 @@ import { CollaborationChart } from './collaboration-chart';
const features = [ const features = [
{ {
title: 'Visualize your data', title: 'Flexible data visualization',
description: description:
'See your data in a visual way. You can create advanced reports and more to understand', 'Build line charts, bar charts, sankey flows, and custom dashboards. Combine metrics from any event into a single view.',
icon: ChartBarIcon, icon: ChartBarIcon,
slug: 'data-visualization', slug: 'data-visualization',
}, },
{ {
title: 'Share & Collaborate', title: 'Share & Collaborate',
description: description:
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.', 'Invite unlimited team members with org-wide or project-level access. Share dashboards publicly or lock them behind a password.',
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
slug: 'share-and-collaborate', slug: 'share-and-collaborate',
}, },
{ {
title: 'Integrations', title: 'Integrations & Webhooks',
description: description:
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.', 'Forward events to your own systems or third-party tools. Connect OpenPanel to Slack, your data warehouse, or any webhook endpoint.',
icon: WorkflowIcon, icon: WorkflowIcon,
slug: 'integrations', slug: 'integrations',
}, },

View File

@@ -43,9 +43,9 @@ export function DataPrivacy() {
/> />
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust." description="GDPR compliant and privacy-friendly analytics without cookies or invasive tracking. Data is EU hosted, and a Data Processing Agreement (DPA) is available to sign."
illustration={<PrivacyIllustration />} illustration={<PrivacyIllustration />}
title="Privacy-first" title="GDPR compliant"
variant="large" variant="large"
/> />
<FeatureCard <FeatureCard

View File

@@ -0,0 +1,68 @@
import { FeatureCard } from '@/components/feature-card';
import { ConversionsIllustration } from '@/components/illustrations/conversions';
import { GoogleSearchConsoleIllustration } from '@/components/illustrations/google-search-console';
import { RevenueIllustration } from '@/components/illustrations/revenue';
import { Section, SectionHeader } from '@/components/section';
function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const features = [
{
title: 'Revenue Tracking',
description:
'Connect payment events to track MRR and see which referrers drive the most revenue.',
illustration: wrap(<RevenueIllustration />),
link: {
href: '/features/revenue-tracking',
children: 'Track revenue',
},
},
{
title: 'Conversion Tracking',
description:
'Monitor conversion rates over time and break down by A/B variant, country, or device. Catch regressions before they cost you.',
illustration: wrap(<ConversionsIllustration />),
link: {
href: '/features/conversion',
children: 'Track conversions',
},
},
{
title: 'Google Search Console',
description:
'See which search queries bring organic traffic and how visitors convert after landing. Your SEO and product data, in one place.',
illustration: wrap(<GoogleSearchConsoleIllustration />),
link: {
href: '/features/integrations',
children: 'View integrations',
},
},
];
export function FeatureSpotlight() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
description="OpenPanel goes beyond page views. Track revenue, monitor conversions, and connect your SEO data — all without switching tools."
label="GROWTH TOOLS"
title="Built for teams who ship and measure"
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => (
<FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description}
illustration={feature.illustration}
key={feature.title}
link={feature.link}
title={feature.title}
/>
))}
</div>
</Section>
);
}

View File

@@ -5,14 +5,14 @@ import {
CalendarIcon, CalendarIcon,
CookieIcon, CookieIcon,
CreditCardIcon, CreditCardIcon,
DatabaseIcon,
GithubIcon, GithubIcon,
ServerIcon, ShieldCheckIcon,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { Competition } from '@/components/competition'; import { Competition } from '@/components/competition';
import { EuFlag } from '@/components/eu-flag';
import { GetStartedButton } from '@/components/get-started-button'; import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks'; import { Perks } from '@/components/perks';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -21,10 +21,10 @@ import { cn } from '@/lib/utils';
const perks = [ const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon }, { text: 'Free trial 30 days', icon: CalendarIcon },
{ text: 'No credit card required', icon: CreditCardIcon }, { text: 'No credit card required', icon: CreditCardIcon },
{ text: 'GDPR compliant', icon: ShieldCheckIcon },
{ text: 'EU hosted', icon: EuFlag },
{ text: 'Cookie-less tracking', icon: CookieIcon }, { text: 'Cookie-less tracking', icon: CookieIcon },
{ text: 'Open-source', icon: GithubIcon }, { text: 'Open-source', icon: GithubIcon },
{ text: 'Your data, your rules', icon: DatabaseIcon },
{ text: 'Self-hostable', icon: ServerIcon },
]; ];
const aspectRatio = 2946 / 1329; const aspectRatio = 2946 / 1329;
@@ -90,7 +90,7 @@ export function Hero() {
TRUSTED BY 1,000+ PROJECTS TRUSTED BY 1,000+ PROJECTS
</div> </div>
<h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl"> <h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl">
OpenPanel - The open-source alternative to <Competition /> The open-source alternative to <Competition />
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
An open-source web and product analytics platform that combines the An open-source web and product analytics platform that combines the

View File

@@ -55,6 +55,9 @@ export function Pricing() {
<div className="col mt-8 w-full items-baseline md:mt-auto"> <div className="col mt-8 w-full items-baseline md:mt-auto">
{selected ? ( {selected ? (
<> <>
<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 className="row items-end gap-3"> <div className="row items-end gap-3">
<NumberFlow <NumberFlow
className="font-bold text-5xl" className="font-bold text-5xl"
@@ -67,9 +70,6 @@ export function Pricing() {
locales={'en-US'} locales={'en-US'}
value={selected.price} 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>
<div className="row w-full justify-between"> <div className="row w-full justify-between">
<span className="-mt-2 text-muted-foreground/80 text-sm"> <span className="-mt-2 text-muted-foreground/80 text-sm">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { QuoteIcon } from 'lucide-react'; import { QuoteIcon, StarIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { FeatureCardBackground } from '@/components/feature-card'; import { FeatureCardBackground } from '@/components/feature-card';
@@ -94,13 +94,22 @@ export function WhyOpenPanel() {
))} ))}
</div> </div>
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2"> <div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
{quotes.map((quote) => ( {quotes.slice(0, 2).map((quote) => (
<figure <figure
className="group px-4 py-4 md:odd:border-r" className="group px-4 py-4 md:odd:border-r"
key={quote.author} key={quote.author}
> >
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" /> <div className="row items-center justify-between">
<blockquote className="prose text-xl"> <QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
<div className="row gap-1">
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
</div>
</div>
<blockquote className="prose text-justify text-xl">
<Markdown>{quote.quote}</Markdown> <Markdown>{quote.quote}</Markdown>
</blockquote> </blockquote>
<figcaption className="row mt-4 justify-between text-muted-foreground text-sm"> <figcaption className="row mt-4 justify-between text-muted-foreground text-sm">

View File

@@ -1,5 +1,6 @@
import { AnalyticsInsights } from './_sections/analytics-insights'; import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration'; import { Collaboration } from './_sections/collaboration';
import { FeatureSpotlight } from './_sections/feature-spotlight';
import { CtaBanner } from './_sections/cta-banner'; import { CtaBanner } from './_sections/cta-banner';
import { DataPrivacy } from './_sections/data-privacy'; import { DataPrivacy } from './_sections/data-privacy';
import { Faq } from './_sections/faq'; import { Faq } from './_sections/faq';
@@ -57,6 +58,7 @@ export default function HomePage() {
<Hero /> <Hero />
<WhyOpenPanel /> <WhyOpenPanel />
<AnalyticsInsights /> <AnalyticsInsights />
<FeatureSpotlight />
<Collaboration /> <Collaboration />
<Testimonials /> <Testimonials />
<Pricing /> <Pricing />

View File

@@ -0,0 +1,37 @@
function star(cx: number, cy: number, outerR: number, innerR: number) {
const pts: string[] = [];
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (i * Math.PI) / 5 - Math.PI / 2;
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
return pts.join(' ');
}
const STARS = Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 - 90) * (Math.PI / 180);
return {
x: 12 + 5 * Math.cos(angle),
y: 8 + 5 * Math.sin(angle),
};
});
export function EuFlag({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 16"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="#003399" height="16" rx="1.5" width="24" />
{STARS.map((s, i) => (
<polygon
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
fill="#FFCC00"
points={star(s.x, s.y, 1.1, 0.45)}
/>
))}
</svg>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const variantA = [28, 31, 29, 34, 32, 36, 35, 38, 37, 40, 39, 42];
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">
{/* 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">
<div className="row items-center gap-1.5">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Variant A
</span>
</div>
<span className="font-bold font-mono text-xl">28.4%</span>
<SimpleChart
height={24}
points={variantA}
strokeColor="var(--foreground)"
width={200}
/>
</div>
<div className="col flex-1 gap-1 rounded-xl border border-emerald-500/30 bg-card p-3 transition-all delay-75 duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[9px] text-emerald-600 dark:text-emerald-400">
Variant B
</span>
</div>
<span className="font-bold font-mono text-xl text-emerald-500">
41.2%
</span>
<SimpleChart
height={24}
points={variantB}
strokeColor="rgb(34, 197, 94)"
width={200}
/>
</div>
</div>
{/* 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">
Breakdown by experiment variant
</span>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-foreground/50"
style={{ width: '57%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">A: 57%</span>
</div>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-emerald-500"
style={{ width: '82%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">B: 82%</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
const queries = [
{
query: 'openpanel analytics',
clicks: 312,
impressions: '4.1k',
pos: 1.2,
},
{
query: 'open source mixpanel alternative',
clicks: 187,
impressions: '3.8k',
pos: 2.4,
},
{
query: 'web analytics without cookies',
clicks: 98,
impressions: '2.2k',
pos: 4.7,
},
];
export function GoogleSearchConsoleIllustration() {
return (
<div className="col h-full gap-2 px-4 pt-5 pb-3">
{/* Top stats */}
<div className="row mb-1 gap-2">
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Clicks
</span>
<span className="font-bold font-mono text-sm">740</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Impr.
</span>
<span className="font-bold font-mono text-sm">13k</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. CTR
</span>
<span className="font-bold font-mono text-sm">5.7%</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. Pos
</span>
<span className="font-bold font-mono text-sm">2.8</span>
</div>
</div>
{/* Query table */}
<div className="flex-1 overflow-hidden rounded-xl border border-border bg-card">
<div className="row border-border border-b px-3 py-1.5">
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
Query
</span>
<span className="w-10 text-right text-[8px] text-muted-foreground uppercase tracking-wider">
Pos
</span>
</div>
{queries.map((q, i) => (
<div
className="row items-center border-border/50 border-b px-3 py-1.5 last:border-0"
key={q.query}
style={{ opacity: 1 - i * 0.18 }}
>
<span className="flex-1 truncate text-[9px]">{q.query}</span>
<span className="w-10 text-right font-mono text-[9px] text-muted-foreground">
{q.pos}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CheckCircleIcon } from 'lucide-react';
export function NotificationsIllustration() {
return (
<div className="col h-full justify-center gap-3 px-6 py-4">
{/* Funnel completion notification */}
<div className="col gap-2 rounded-xl border border-border bg-card p-4 shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-2">
<CheckCircleIcon className="size-4 shrink-0 text-emerald-500" />
<span className="font-semibold text-xs">Funnel completed</span>
<span className="ml-auto text-[9px] text-muted-foreground">
just now
</span>
</div>
<p className="font-medium text-sm">Signup Flow 142 today</p>
<div className="row items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-emerald-500"
style={{ width: '71%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">
71% conversion
</span>
</div>
</div>
{/* Notification rule */}
<div className="col gap-1.5 px-3 opacity-80">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
Notification rule
</span>
<div className="row flex-wrap items-center gap-1.5">
<span className="text-[9px] text-muted-foreground">When</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Signup Flow
</span>
<span className="text-[9px] text-muted-foreground">completes </span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
#growth
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
const cohorts = [
{ label: 'Week 1', values: [100, 68, 45, 38, 31] },
{ label: 'Week 2', values: [100, 72, 51, 42, 35] },
{ label: 'Week 3', values: [100, 65, 48, 39, null] },
{ label: 'Week 4', values: [100, 70, null, null, null] },
];
const headers = ['Day 0', 'Day 1', 'Day 7', 'Day 14', 'Day 30'];
function cellStyle(v: number | null) {
if (v === null) {
return {
backgroundColor: 'transparent',
borderColor: 'var(--border)',
color: 'var(--muted-foreground)',
};
}
const opacity = 0.12 + (v / 100) * 0.7;
return {
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
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="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"
>
{h}
</div>
))}
</div>
{cohorts.map(({ label, values }) => (
<div key={label} className="row flex-1 gap-1">
<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
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}%` : '—'}
</div>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const revenuePoints = [28, 34, 31, 40, 37, 44, 41, 50, 47, 56, 59, 65];
const referrers = [
{ name: 'google.com', amount: '$3,840', pct: 46 },
{ name: 'twitter.com', amount: '$1,920', pct: 23 },
{ name: 'github.com', amount: '$1,260', pct: 15 },
{ name: 'direct', amount: '$1,400', pct: 16 },
];
export function RevenueIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* 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">
MRR
</span>
<span className="font-bold font-mono text-xl text-emerald-500">
$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>
<SimpleChart
className="mt-1 flex-1"
height={36}
points={revenuePoints}
strokeColor="rgb(34, 197, 94)"
width={400}
/>
</div>
</div>
{/* 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">
Referrer
</span>
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
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"
key={r.name}
>
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
{r.name}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<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">
{r.amount}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { PlayIcon } from 'lucide-react';
export function SessionReplayIllustration() {
return (
<div className="h-full px-6 pb-3 pt-4">
<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="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" />
<div className="mx-2 flex-1 rounded bg-background/80 px-2 py-0.5 text-[8px] text-muted-foreground">
app.example.com/pricing
</div>
</div>
{/* Page content */}
<div className="relative flex-1 overflow-hidden p-3">
<div className="mb-2 h-2 w-20 rounded-full bg-muted/60" />
<div className="mb-4 h-2 w-32 rounded-full bg-muted/40" />
<div className="row mb-3 gap-2">
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
</div>
<div className="mb-2 h-2 w-28 rounded-full bg-muted/30" />
<div className="h-2 w-24 rounded-full bg-muted/20" />
{/* Click heatspot */}
<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="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
</div>
{/* Cursor trail */}
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ overflow: 'visible' }}
>
<path
d="M 18% 22% Q 42% 28% 62% 48%"
fill="none"
stroke="rgb(59 130 246 / 0.35)"
strokeDasharray="3 2"
strokeWidth="1"
/>
</svg>
{/* Cursor */}
<div
className="absolute"
style={{
left: 'calc(62% + 8px)',
top: 'calc(48% + 6px)',
}}
>
<svg fill="none" height="12" viewBox="0 0 10 12" width="10">
<path
d="M0 0L0 10L3 7L5 11L6.5 10.5L4.5 6.5L8 6L0 0Z"
fill="var(--foreground)"
/>
</svg>
</div>
</div>
{/* Playback bar */}
<div className="row shrink-0 items-center gap-2 border-t border-border 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="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
style={{ width: '42%' }}
/>
</div>
<span className="font-mono text-[8px] text-muted-foreground">
0:52 / 2:05
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,188 +1,165 @@
'use client'; 'use client';
import { SimpleChart } from '@/components/simple-chart';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react'; import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [ const VISITOR_DATA = [1840, 2100, 1950, 2400, 2250, 2650, 2980];
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: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
];
const SOURCES = [
{ {
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com', icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
name: 'Google', name: 'google.com',
percentage: 49, pct: 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', icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter', name: 'twitter.com',
percentage: 10, pct: 21,
value: 412, },
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
name: 'github.com',
pct: 14,
}, },
]; ];
const COUNTRIES = [ function AreaChart({ data }: { data: number[] }) {
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 }, const max = Math.max(...data);
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 }, const w = 400;
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 }, const h = 64;
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 }, 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 area = `${line} L ${w},${h} L 0,${h} Z`;
const last = pts[pts.length - 1];
return (
<svg className="w-full" viewBox={`0 0 ${w} ${h + 4}`}>
<defs>
<linearGradient id="wa-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.25" />
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#wa-fill)" />
<path
d={line}
fill="none"
stroke="rgb(59 130 246)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<circle cx={last.x} cy={last.y} fill="rgb(59 130 246)" r="3" />
<circle
cx={last.x}
cy={last.y}
fill="none"
r="6"
stroke="rgb(59 130 246)"
strokeOpacity="0.3"
strokeWidth="1.5"
/>
</svg>
);
}
export function WebAnalyticsIllustration() { export function WebAnalyticsIllustration() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0); const [liveVisitors, setLiveVisitors] = useState(47);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const values = [47, 51, 44, 53, 49, 56];
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length); let i = 0;
}, 3000); const id = setInterval(() => {
i = (i + 1) % values.length;
return () => clearInterval(interval); setLiveVisitors(values[i]);
}, 2500);
return () => clearInterval(id);
}, []); }, []);
return ( return (
<div className="px-12 group aspect-video"> <div className="aspect-video col gap-2.5 p-5">
<div className="relative h-full col"> {/* Header */}
<MetricCard <div className="row items-center justify-between">
title="Session duration" <div className="row items-center gap-1.5">
value="3m 23s" <span className="relative flex h-1.5 w-1.5">
change="3%" <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]} <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
color="var(--foreground)" </span>
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300" <span className="text-[10px] font-medium text-muted-foreground">
/> <NumberFlow value={liveVisitors} /> online now
<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> </span>
<NumberFlow value={value} locales={'en-US'} />
</div> </div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] text-muted-foreground">
Last 7 days
</span>
</div>
{/* KPI tiles */}
<div className="grid grid-cols-4 gap-1.5">
{STATS.map((stat) => (
<div
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="font-mono font-semibold text-xs leading-tight">
{stat.formatted ??
(stat.value !== null ? (
<NumberFlow locales="en-US" value={stat.value} />
) : null)}
</span>
<span
className={`text-[8px] ${stat.up ? 'text-emerald-500' : 'text-red-400'}`}
>
{stat.up ? '↑' : '↓'} {stat.change}%
</span>
</div>
))}
</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>
<AreaChart data={VISITOR_DATA} />
<div className="row justify-between px-0.5">
{DAYS.map((d) => (
<span className="text-[7px] text-muted-foreground" key={d}>
{d}
</span>
))}
</div>
</div>
{/* Traffic sources */}
<div className="row gap-1.5">
{SOURCES.map((src) => (
<div
className="row flex-1 items-center gap-1.5 overflow-hidden rounded-lg border bg-card px-2 py-1.5"
key={src.name}
>
<Image
alt={src.name}
className="rounded-[2px] object-contain"
height={10}
src={src.icon}
width={10}
/>
<span className="flex-1 truncate text-[9px]">{src.name}</span>
<span className="font-mono text-[9px] text-muted-foreground">
{src.pct}%
</span>
</div>
))}
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,13 @@
import type React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
export function Perks({ export function Perks({
perks, perks,
className, className,
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) { }: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
return ( return (
<ul className={cn('grid grid-cols-2 gap-2', className)}> <ul className={cn('grid grid-cols-2 gap-2', className)}>
{perks.map((perk) => ( {perks.map((perk) => (