feat: new public website
This commit is contained in:
70
apps/public/src/app/(home)/_sections/analytics-insights.tsx
Normal file
70
apps/public/src/app/(home)/_sections/analytics-insights.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader, SectionLabel } from '@/components/section';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
DollarSignIcon,
|
||||
GlobeIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import { ProductAnalyticsIllustration } from './illustrations/product-analytics';
|
||||
import { WebAnalyticsIllustration } from './illustrations/web-analytics';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Revenue tracking',
|
||||
description:
|
||||
'Track revenue from your payments and get insights into your revenue sources.',
|
||||
icon: DollarSignIcon,
|
||||
},
|
||||
{
|
||||
title: 'Profiles & Sessions',
|
||||
description:
|
||||
'Track individual users and their complete journey across your platform.',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
title: 'Event Tracking',
|
||||
description:
|
||||
'Capture every important interaction with flexible event tracking.',
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
];
|
||||
|
||||
export function AnalyticsInsights() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
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">
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Web Analytics"
|
||||
description="Understand your website performance with privacy-first analytics and clear, actionable insights."
|
||||
illustration={<WebAnalyticsIllustration />}
|
||||
className="px-0 **:data-content:px-6"
|
||||
/>
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Product Analytics"
|
||||
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends."
|
||||
illustration={<ProductAnalyticsIllustration />}
|
||||
className="px-0 **:data-content:px-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
171
apps/public/src/app/(home)/_sections/collaboration-chart.tsx
Normal file
171
apps/public/src/app/(home)/_sections/collaboration-chart.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { FeatureCardContainer } from '@/components/feature-card';
|
||||
import { MoreVerticalIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
// Sample data for the last 7 days
|
||||
const data = [
|
||||
{ day: 'Mon', visitors: 1200, revenue: 1250 },
|
||||
{ day: 'Tue', visitors: 1450, revenue: 1890 },
|
||||
{ day: 'Wed', visitors: 1320, revenue: 1520 },
|
||||
{ day: 'Thu', visitors: 1580, revenue: 2100 },
|
||||
{ day: 'Fri', visitors: 1420, revenue: 1750 },
|
||||
{ day: 'Sat', visitors: 1180, revenue: 1100 },
|
||||
{ day: 'Sun', visitors: 1250, revenue: 1380 },
|
||||
];
|
||||
|
||||
// Custom tooltip component
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const visitors =
|
||||
payload.find((p: any) => p.dataKey === 'visitors')?.value || 0;
|
||||
const revenue =
|
||||
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">
|
||||
<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">
|
||||
<span>Revenue</span> <span>${revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function CollaborationChart() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(1); // Default to Tue (index 1)
|
||||
|
||||
// Calculate metrics from active point or default
|
||||
const activeData = useMemo(() => {
|
||||
return activeIndex !== null ? data[activeIndex] : data[1];
|
||||
}, [activeIndex]);
|
||||
|
||||
const totalVisitors = activeData.visitors;
|
||||
const totalRevenue = activeData.revenue;
|
||||
|
||||
return (
|
||||
<FeatureCardContainer className="col gap-4 h-full">
|
||||
{/* 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>
|
||||
</div>
|
||||
<button
|
||||
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%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
onMouseMove={(state) => {
|
||||
if (state?.activeTooltipIndex !== undefined) {
|
||||
setActiveIndex(state.activeTooltipIndex);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setActiveIndex(null)}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
domain={[0, 2400]}
|
||||
hide
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={false} />
|
||||
{/* Revenue bars */}
|
||||
<Bar yAxisId="right" dataKey="revenue" radius={4}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${entry.day}`}
|
||||
className={
|
||||
activeIndex === index
|
||||
? 'fill-emerald-500' // Lighter green on hover
|
||||
: 'fill-foreground/30' // Default green
|
||||
}
|
||||
style={{ transition: 'fill 0.2s ease' }}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="visitors"
|
||||
strokeWidth={2}
|
||||
stroke="var(--foreground)"
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 center-center">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold font-mono">
|
||||
{totalVisitors.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Visitors</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold font-mono text-emerald-500">
|
||||
${totalRevenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Revenue</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
66
apps/public/src/app/(home)/_sections/collaboration.tsx
Normal file
66
apps/public/src/app/(home)/_sections/collaboration.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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,
|
||||
DollarSignIcon,
|
||||
LayoutDashboardIcon,
|
||||
RocketIcon,
|
||||
WorkflowIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { CollaborationChart } from './collaboration-chart';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Visualize your data',
|
||||
description:
|
||||
'See your data in a visual way. You can create advanced reports and more to understand',
|
||||
icon: ChartBarIcon,
|
||||
},
|
||||
{
|
||||
title: 'Share & Collaborate',
|
||||
description:
|
||||
'Build interactive dashboards and share insights with your team. Export reports, set up notifications, and keep everyone aligned.',
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
description:
|
||||
'Get notified when new events are created, or forward specific events to your own systems with our east to use integrations.',
|
||||
icon: WorkflowIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function Collaboration() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-16">
|
||||
<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."
|
||||
/>
|
||||
|
||||
<GetStartedButton className="mt-6" />
|
||||
|
||||
<div className="col gap-6 mt-16">
|
||||
{features.map((feature) => (
|
||||
<div className="col gap-2" key={feature.title}>
|
||||
<h3 className="font-semibold">
|
||||
<feature.icon className="size-6 inline-block mr-2 relative -top-0.5" />
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
81
apps/public/src/app/(home)/_sections/cta-banner.tsx
Normal file
81
apps/public/src/app/(home)/_sections/cta-banner.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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"
|
||||
height="539"
|
||||
viewBox="0 0 409 539"
|
||||
fill="none"
|
||||
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"
|
||||
stroke="url(#paint0_linear_552_3808)"
|
||||
strokeWidth="123.399"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_552_3808"
|
||||
x1="324.18"
|
||||
y1="186.354"
|
||||
x2="161.365"
|
||||
y2="265.924"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CtaBanner({
|
||||
title = (
|
||||
<>
|
||||
Ready to understand your users better?
|
||||
<br />
|
||||
Start tracking in minutes
|
||||
</>
|
||||
),
|
||||
description = 'Join thousands of companies using OpenPanel. Free 30-day trial, no credit card required. Self-host for free or use our cloud.',
|
||||
ctaText,
|
||||
ctaLink,
|
||||
}: {
|
||||
title?: string | React.ReactNode;
|
||||
description?: string;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="container">
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden border rounded-3xl py-16 px-4 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 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">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
<GetStartedButton className="mt-4" text={ctaText} href={ctaLink} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/public/src/app/(home)/_sections/data-privacy.tsx
Normal file
67
apps/public/src/app/(home)/_sections/data-privacy.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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';
|
||||
|
||||
const secondaryFeatures = [
|
||||
{
|
||||
title: 'Open Source',
|
||||
description:
|
||||
'Full transparency. Audit the code, contribute, fork it, or self-host without lock-in.',
|
||||
icon: GithubIcon,
|
||||
},
|
||||
{
|
||||
title: 'Self-hosting',
|
||||
description:
|
||||
'Deploy OpenPanel anywhere - your server, your cloud, or locally. Full flexibility.',
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
title: 'Lightweight & Fast',
|
||||
description:
|
||||
"A tiny, high-performance tracker that won't slow down your site.",
|
||||
icon: BoltIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function DataPrivacy() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
Built for Control,
|
||||
<br />
|
||||
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">
|
||||
<FeatureCard
|
||||
variant="large"
|
||||
title="Privacy-first"
|
||||
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust."
|
||||
illustration={<PrivacyIllustration />}
|
||||
/>
|
||||
<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 />}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{secondaryFeatures.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
102
apps/public/src/app/(home)/_sections/faq.tsx
Normal file
102
apps/public/src/app/(home)/_sections/faq.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { FaqItem, Faqs } from '@/components/faq';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import Script from 'next/script';
|
||||
|
||||
const faqData = [
|
||||
{
|
||||
question: 'Does OpenPanel have a free tier?',
|
||||
answer:
|
||||
'For our Cloud plan we offer a 30 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan. OpenPanel is also open-source and you can self-host it for free!\n\nWhy does OpenPanel not have a free tier? We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
|
||||
},
|
||||
{
|
||||
question: 'Is everything really unlimited?',
|
||||
answer:
|
||||
'Yes! With OpenPanel, you get unlimited websites/apps, unlimited users, unlimited dashboards, unlimited charts, and unlimited tracked profiles.\n\nThe only limit is the number of events you track per month, which you choose based on your needs.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between web and product analytics?',
|
||||
answer:
|
||||
'Web analytics focuses on website traffic, page views, and visitor behavior. Product analytics goes deeper, tracking user interactions, events, and product usage patterns.\n\nOpenPanel combines both, giving you a complete view of your users and product performance.',
|
||||
},
|
||||
{
|
||||
question: 'Do I need to modify my code to use OpenPanel?',
|
||||
answer:
|
||||
'OpenPanel offers multiple SDKs and integration options. For most frameworks, you can get started with just a few lines of code.\n\nWe provide SDKs for React, Next.js, Vue, Astro, and many more. Check our documentation for your specific framework.',
|
||||
},
|
||||
{
|
||||
question: 'Is my data GDPR compliant?',
|
||||
answer:
|
||||
"Yes! OpenPanel is designed with privacy in mind. We use cookie-less tracking, don't collect personal data without consent, and give you full control over your data.\n\nYou can self-host to ensure complete data sovereignty.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to other analytics tools?',
|
||||
answer:
|
||||
'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare).',
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Mixpanel?',
|
||||
answer:
|
||||
"OpenPanel offers similar powerful product analytics features as Mixpanel, but with the added benefits of being open-source, more affordable, and including web analytics capabilities.\n\nYou get Mixpanel's power with Plausible's simplicity.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Plausible?',
|
||||
answer:
|
||||
"OpenPanel shares Plausible's privacy-first approach and simplicity, but adds powerful product analytics capabilities.\n\nWhile Plausible focuses on web analytics, OpenPanel combines both web and product analytics in one platform.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Google Analytics?',
|
||||
answer:
|
||||
"OpenPanel is a privacy-first alternative to Google Analytics. Unlike GA, we don't use cookies, respect user privacy, and give you full control over your data.\n\nPlus, you get product analytics features that GA doesn't offer.",
|
||||
},
|
||||
{
|
||||
question: 'Can I export my data?',
|
||||
answer:
|
||||
'Absolutely! You own your data and can export it anytime. We have API endpoints to get all raw data that we have access to.\n\nIf you self-host, you have direct access and own all your data. For our cloud service, you can always reach out to us if you want a database dump of all your data—perfect if you want to move from cloud to self-hosting.\n\nWe have no lock-in whatsoever.',
|
||||
},
|
||||
{
|
||||
question: 'What kind of support do you offer?',
|
||||
answer:
|
||||
'We offer support through our documentation, GitHub issues, and Discord community. For paid plans, we provide email support.\n\nOur team is committed to helping you succeed with OpenPanel.',
|
||||
},
|
||||
];
|
||||
|
||||
export function Faq() {
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqData.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: q.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
title="FAQ"
|
||||
description="Some of the most common questions we get asked."
|
||||
/>
|
||||
<Faqs>
|
||||
{faqData.map((faq) => (
|
||||
<FaqItem key={faq.question} question={faq.question}>
|
||||
{faq.answer}
|
||||
</FaqItem>
|
||||
))}
|
||||
</Faqs>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
188
apps/public/src/app/(home)/_sections/hero.tsx
Normal file
188
apps/public/src/app/(home)/_sections/hero.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'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';
|
||||
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
const aspectRatio = 2946 / 1329;
|
||||
const width = 2346;
|
||||
const height = width / aspectRatio;
|
||||
|
||||
function HeroImage({ className }: { className?: string }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
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)}
|
||||
style={{
|
||||
left: `calc(50% - ${width / 2}px - 50px)`,
|
||||
top: -270,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/hero-dark.webp"
|
||||
alt="Hero"
|
||||
width={width}
|
||||
height={height}
|
||||
className="hidden dark:block"
|
||||
style={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height,
|
||||
}}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
<Image
|
||||
src="/hero-light.webp"
|
||||
alt="Hero"
|
||||
width={width}
|
||||
height={height}
|
||||
className="dark:hidden"
|
||||
style={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height,
|
||||
}}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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 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]">
|
||||
An open-source alternative to <Competition />
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines the
|
||||
power of Mixpanel with the ease of Plausible and one of the best
|
||||
Google Analytics replacements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-4">
|
||||
<GetStartedButton />
|
||||
<Button size="lg" variant="outline" asChild className="px-6">
|
||||
<Link
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
Test live demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Perks perks={perks} />
|
||||
</div>
|
||||
|
||||
<div className="col sm:w-1/2 relative group max-sm:px-4">
|
||||
<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',
|
||||
])}
|
||||
>
|
||||
{/* 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 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>
|
||||
{/* URL bar */}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
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"
|
||||
>
|
||||
<span className="text-muted-foreground flex-1">
|
||||
https://demo.openpanel.dev
|
||||
</span>
|
||||
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroContainer({
|
||||
children,
|
||||
className,
|
||||
divider = true,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
divider?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section
|
||||
className={cn('relative z-10', divider && 'overflow-hidden', className)}
|
||||
>
|
||||
<div className="absolute inset-0 w-screen overflow-x-clip">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<div
|
||||
className="container relative col sm:row 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)]" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
119
apps/public/src/app/(home)/_sections/pricing.tsx
Normal file
119
apps/public/src/app/(home)/_sections/pricing.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
const features = [
|
||||
'Unlimited websites or apps',
|
||||
'Unlimited users',
|
||||
'Unlimited dashboards',
|
||||
'Unlimited charts',
|
||||
'Unlimited tracked profiles',
|
||||
'Yes, we have no limits or hidden costs',
|
||||
];
|
||||
|
||||
export function Pricing() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(2);
|
||||
const selected = PRICING[selectedIndex];
|
||||
|
||||
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">
|
||||
Choose how many events you'll track this month
|
||||
</p>
|
||||
<div className="row flex-wrap gap-2">
|
||||
{PRICING.map((tier, index) => (
|
||||
<Button
|
||||
key={tier.price}
|
||||
variant={selectedIndex === index ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn('h-8 rounded-full relative px-4 border')}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
{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')}
|
||||
onClick={() => setSelectedIndex(-1)}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col items-baseline mt-8 md:mt-auto w-full">
|
||||
{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">
|
||||
Per month
|
||||
</span>
|
||||
<span className="text-muted-foreground/80 text-sm -mt-2">
|
||||
+ VAT if applicable
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-lg">
|
||||
Contact us at{' '}
|
||||
<a className="underline" href="mailto:hello@openpanel.dev">
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
to get a custom quote.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col gap-8 justify-center flex-1 shrink-0">
|
||||
<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."
|
||||
/>
|
||||
</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" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
30
apps/public/src/app/(home)/_sections/sdks.tsx
Normal file
30
apps/public/src/app/(home)/_sections/sdks.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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';
|
||||
|
||||
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"
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||
{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>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</div>
|
||||
</FeatureCardContainer>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
193
apps/public/src/app/(home)/_sections/testimonials.tsx
Normal file
193
apps/public/src/app/(home)/_sections/testimonials.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
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 = [
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-steven.jpg',
|
||||
name: 'Steven Tey',
|
||||
handle: 'steventey',
|
||||
content: [
|
||||
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
|
||||
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
|
||||
'Built by @CarlLindesvard and it’s already tracking 750K+ events 🤩',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-pontus.jpg',
|
||||
name: 'Pontus Abrahamsson - oss/acc',
|
||||
handle: 'pontusab',
|
||||
content: ['Thanks, OpenPanel is a beast, love it!'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-piotr.jpg',
|
||||
name: 'Piotr Kulpinski',
|
||||
handle: 'piotrkulpinski',
|
||||
content: [
|
||||
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
|
||||
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-greg.png',
|
||||
name: 'greg hodson 🍜',
|
||||
handle: 'h0dson',
|
||||
content: ['i second this, openpanel is killing it'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-jacob.jpg',
|
||||
name: 'Jacob 🍀 Build in Public',
|
||||
handle: 'javayhuwx',
|
||||
content: [
|
||||
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
|
||||
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
|
||||
'#buildinpublic #indiehackers',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-lee.jpg',
|
||||
name: 'Lee',
|
||||
handle: 'DutchEngIishman',
|
||||
content: [
|
||||
'Day two of marketing.',
|
||||
'I like this upward trend..',
|
||||
'P.S. website went live on Sunday',
|
||||
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const isPausedRef = useRef(false);
|
||||
|
||||
// Duplicate items to create the illusion of infinite scrolling
|
||||
const duplicatedTestimonials = useMemo(
|
||||
() => [...testimonials, ...testimonials],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElement = scrollerRef.current;
|
||||
if (!scrollerElement) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
// When we've scrolled to the end of the first set, reset to the beginning
|
||||
// This creates a seamless infinite scroll effect
|
||||
const scrollWidth = scrollerElement.scrollWidth;
|
||||
const clientWidth = scrollerElement.clientWidth;
|
||||
const scrollLeft = scrollerElement.scrollLeft;
|
||||
|
||||
// Reset scroll position when we reach halfway (end of first set)
|
||||
if (scrollLeft + clientWidth >= scrollWidth / 2) {
|
||||
scrollerElement.scrollLeft = scrollLeft - scrollWidth / 2;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll functionality
|
||||
const autoScroll = () => {
|
||||
if (!isPausedRef.current && scrollerElement) {
|
||||
scrollerElement.scrollLeft += 0.5; // Adjust speed here
|
||||
animationFrameRef.current = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
};
|
||||
|
||||
scrollerElement.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Start auto-scrolling
|
||||
animationFrameRef.current = requestAnimationFrame(autoScroll);
|
||||
|
||||
// Pause on hover
|
||||
const handleMouseEnter = () => {
|
||||
isPausedRef.current = true;
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
isPausedRef.current = false;
|
||||
animationFrameRef.current = requestAnimationFrame(autoScroll);
|
||||
};
|
||||
|
||||
scrollerElement.addEventListener('mouseenter', handleMouseEnter);
|
||||
scrollerElement.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
scrollerElement.removeEventListener('scroll', handleScroll);
|
||||
scrollerElement.removeEventListener('mouseenter', handleMouseEnter);
|
||||
scrollerElement.removeEventListener('mouseleave', handleMouseLeave);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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."
|
||||
/>
|
||||
</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"
|
||||
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"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to left, hsl(var(--background)), transparent)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<InfiniteMovingCards
|
||||
items={testimonials}
|
||||
direction="left"
|
||||
pauseOnHover
|
||||
speed="slow"
|
||||
className="gap-8"
|
||||
renderItem={(item) => (
|
||||
<TwitterCard
|
||||
name={item.name}
|
||||
handle={item.handle}
|
||||
content={item.content}
|
||||
avatarUrl={item.avatarUrl}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
82
apps/public/src/app/(home)/_sections/why-openpanel.tsx
Normal file
82
apps/public/src/app/(home)/_sections/why-openpanel.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { FeatureCardBackground } from '@/components/feature-card';
|
||||
import { Section, SectionHeader, SectionLabel } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: 'Helpy UI',
|
||||
url: 'https://helpy-ui.com',
|
||||
logo: '/logos/helpy-ui.png',
|
||||
className: 'size-12',
|
||||
},
|
||||
{
|
||||
name: 'KiddoKitchen',
|
||||
url: 'https://kiddokitchen.se',
|
||||
logo: '/logos/kiddokitchen.png',
|
||||
},
|
||||
{
|
||||
name: 'Maneken',
|
||||
url: 'https://maneken.app',
|
||||
logo: '/logos/maneken.png',
|
||||
},
|
||||
{
|
||||
name: 'Midday',
|
||||
url: 'https://midday.ai',
|
||||
logo: '/logos/midday.png',
|
||||
},
|
||||
{
|
||||
name: 'Screenzen',
|
||||
url: 'https://www.screenzen.co',
|
||||
logo: '/logos/screenzen.png',
|
||||
},
|
||||
{
|
||||
name: 'Tiptip',
|
||||
url: 'https://tiptip.id',
|
||||
logo: '/logos/tiptip.png',
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyOpenPanel() {
|
||||
return (
|
||||
<Section className="container gap-16">
|
||||
<SectionHeader
|
||||
label="Trusted by builders"
|
||||
title="Join thousands of companies using OpenPanel to understand their users"
|
||||
/>
|
||||
<div className="col overflow-hidden">
|
||||
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4">
|
||||
USED BY
|
||||
</SectionLabel>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4">
|
||||
{images.map((image) => (
|
||||
<div key={image.logo} className="px-4 border-r last:border-r-0 ">
|
||||
<a
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
key={image.logo}
|
||||
className={cn('relative group center-center aspect-square')}
|
||||
title={image.name}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
<Image
|
||||
src={image.logo}
|
||||
alt={image.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className={cn(
|
||||
'size-16 object-contain dark:invert',
|
||||
image.className,
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user