feature(public,docs): new public website and docs

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-11-13 21:15:46 +01:00
parent fc2a019e1d
commit a022cb4831
234 changed files with 9341 additions and 6154 deletions

View File

@@ -0,0 +1,152 @@
import { cn } from '@/lib/utils';
interface SwirlProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
export function SingleSwirl({ className, ...props }: SwirlProps) {
return (
<svg
width="1193"
height="634"
viewBox="0 0 1193 634"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('text-white', className)}
{...props}
>
<g filter="url(#filter0_f_290_140)">
<path
d="M996.469 546.016C728.822 501.422 310.916 455.521 98.1817 18.6728"
stroke="currentColor"
strokeWidth="26"
/>
</g>
<g filter="url(#filter1_f_290_140)">
<path
d="M780.821 634.792C582.075 610.494 151.698 468.051 20.1495 92.6602"
stroke="currentColor"
/>
</g>
<defs>
<filter
id="filter0_f_290_140"
x="-107.406"
y="-180.919"
width="1299.91"
height="933.658"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="96.95"
result="effect1_foregroundBlur_290_140"
/>
</filter>
<filter
id="filter1_f_290_140"
x="-3.32227"
y="69.4946"
width="807.204"
height="588.793"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="11.5"
result="effect1_foregroundBlur_290_140"
/>
</filter>
</defs>
</svg>
);
}
export function DoubleSwirl({ className, ...props }: SwirlProps) {
return (
<svg
width="1535"
height="1178"
viewBox="0 0 1535 1178"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('text-white', className)}
{...props}
>
<g filter="url(#filter0_f_290_639)">
<path
d="M1392.59 1088C1108.07 603.225 323.134 697.532 143.435 135.494"
stroke="currentColor"
strokeOpacity="0.5"
strokeWidth="26"
/>
</g>
<g filter="url(#filter1_f_290_639)">
<path
d="M1446.57 1014.51C1162.05 529.732 377.111 624.039 197.412 62.0001"
stroke="currentColor"
strokeOpacity="0.06"
strokeWidth="26"
/>
</g>
<defs>
<filter
id="filter0_f_290_639"
x="0.244919"
y="0.679001"
width="1534.37"
height="1224.74"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="65.4"
result="effect1_foregroundBlur_290_639"
/>
</filter>
<filter
id="filter1_f_290_639"
x="160.022"
y="32.9856"
width="1322.77"
height="1013.14"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="12.5"
result="effect1_foregroundBlur_290_639"
/>
</filter>
</defs>
</svg>
);
}

View File

@@ -0,0 +1,8 @@
##### Common options
- `apiUrl` - The url of the openpanel API or your self-hosted instance
- `clientId` - The client id of your application
- `clientSecret` - The client secret of your application (**only required for server-side events**)
- `filter` - A function that will be called before sending an event. If it returns false, the event will not be sent
- `disabled` - If true, the library will not send any events
- `waitForProfile` - If true, the library will wait for the profile to be set before sending events

View File

@@ -0,0 +1,12 @@
import { Callout } from 'fumadocs-ui/components/callout';
import Link from 'next/link';
export function DeviceIdWarning() {
return (
<Callout>
Read more about{' '}
<Link href="/docs/device-id">device id and why you might want it</Link>.
**We recommend not to but it&apos;s up to you.**
</Callout>
);
}

View File

@@ -0,0 +1,125 @@
import { cn } from '@/lib/utils';
import { ChevronRightIcon } from 'lucide-react';
import Link from 'next/link';
export function Feature({
children,
media,
reverse = false,
className,
}: {
children: React.ReactNode;
media?: React.ReactNode;
reverse?: boolean;
className?: string;
}) {
return (
<div
className={cn(
'border rounded-lg bg-background-light overflow-hidden',
className,
)}
>
<div
className={cn(
'grid grid-cols-1 md:grid-cols-2 gap-4 items-center',
!media && '!grid-cols-1',
)}
>
<div className={cn(reverse && 'md:order-last', 'p-10')}>{children}</div>
{media && (
<div
className={cn(
'bg-background-dark h-full',
reverse && 'md:order-first',
)}
>
{media}
</div>
)}
</div>
</div>
);
}
export function FeatureContent({
icon,
title,
content,
className,
}: {
icon?: React.ReactNode;
title: string;
content: string[];
className?: string;
}) {
return (
<div className={className}>
{icon && (
<div className="bg-foreground text-background rounded-md p-4 inline-block mb-1">
{icon}
</div>
)}
<h2 className="text-lg font-medium mb-2">{title}</h2>
<div className="col gap-2">
{content.map((c, i) => (
<p className="text-muted-foreground" key={i.toString()}>
{c}
</p>
))}
</div>
</div>
);
}
export function FeatureList({
title,
items,
className,
cols = 2,
}: {
title: string;
items: React.ReactNode[];
className?: string;
cols?: number;
}) {
return (
<div className={className}>
<h3 className="font-semibold text-sm mb-2">{title}</h3>
<div
className={cn(
'-mx-2 [&>div]:p-2 [&>div]:row [&>div]:items-center [&>div]:gap-2 grid',
cols === 1 && 'grid-cols-1',
cols === 2 && 'grid-cols-2',
cols === 3 && 'grid-cols-3',
)}
>
{items.map((i, j) => (
<div key={j.toString()}>{i}</div>
))}
</div>
</div>
);
}
export function FeatureMore({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) {
return (
<Link
href={href}
className={cn(
'font-medium items-center row justify-between border-t py-4',
className,
)}
>
{children} <ChevronRightIcon className="size-4" strokeWidth={1.5} />
</Link>
);
}

View File

@@ -0,0 +1,22 @@
import Image from 'next/image';
export function Figure({
src,
alt,
caption,
}: { src: string; alt: string; caption: string }) {
return (
<figure className="-mx-4">
<Image
src={src}
alt={alt}
width={1200}
height={800}
className="rounded-lg"
/>
<figcaption className="text-center text-sm text-muted-foreground mt-2">
{caption}
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,144 @@
import { baseOptions } from '@/app/layout.config';
import { MailIcon } from 'lucide-react';
import Link from 'next/link';
import { SingleSwirl } from './Swirls';
import { Logo } from './logo';
import { SectionHeader } from './section';
import { Tag } from './tag';
import { Button } from './ui/button';
export function Footer() {
return (
<div className="mt-32">
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0" />
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-32 opacity-50" />
<div className="container center-center col">
<SectionHeader
tag={<Tag>Discover User Insights</Tag>}
title="Effortless web & product analytics"
description="Simplify your web & product analytics with our user-friendly platform. Collect, analyze, and optimize your data in minutes, for free."
/>
<Button size="lg" variant="secondary" asChild>
<Link href="https://dashboard.openpanel.dev/register">
Get started today!
</Link>
</Button>
</div>
</section>
<footer className="container py-32 text-sm">
<div className="grid grid-cols-1 md:grid-cols-8 gap-12 md:gap-8">
<div className="md:col-span-2">
<Link href="/" className="row items-center font-medium">
<Logo className="h-6" />
{baseOptions.nav?.title}
</Link>
</div>
<div className="col gap-3">
<h3 className="font-medium">Useful links</h3>
<ul className="gap-2 col text-muted-foreground">
<li>
<Link href="/pricing">Pricing</Link>
</li>
<li>
<Link href="/about">About</Link>
</li>
<li>
<Link href="/contact">Contact</Link>
</li>
</ul>
</div>
<div className="col gap-3">
{/* <h3 className="font-medium">Company</h3>
<ul className="gap-2 col text-muted-foreground">
<li>
<Link href="/about">About</Link>
</li>
<li>
<Link href="/contact">Contact</Link>
</li>
</ul> */}
</div>
<div className="col gap-3 md:col-span-2">
<h3 className="font-medium">Comparisons</h3>
<ul className="gap-2 col text-muted-foreground">
<li>
<Link href="/articles/vs-mixpanel">vs Mixpanel</Link>
</li>
</ul>
</div>
<div className="md:col-span-2 items-end col gap-4">
<div className="[&_svg]:size-6 row gap-4">
<Link
title="Go to GitHub"
href="https://github.com/Openpanel-dev/openpanel"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</Link>
<Link title="Go to X" href="https://x.com/openpaneldev">
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>X</title>
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
</svg>
</Link>
<Link
title="Join Discord"
href="https://go.openpanel.dev/discord"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>Discord</title>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
</Link>
<Link title="Send an email" href="mailto:hello@openpanel.dev">
<MailIcon className="size-6" />
</Link>
</div>
<a
target="_blank"
href="https://status.openpanel.dev"
className="row gap-2 items-center border rounded-full px-2 py-1"
rel="noreferrer"
>
<span>Operational</span>
<div className="size-2 bg-emerald-500 rounded-full" />
</a>
</div>
</div>
<div className="col md:row justify-between text-muted-foreground border-t pt-4 mt-16 gap-8">
<div>Copyright © 2024 OpenPanel. All rights reserved.</div>
<div className="col lg:row gap-2 md:gap-4">
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
<Link href="/cookies">Cookie Policy (just kidding)</Link>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Button } from './ui/button';
function formatStars(stars: number) {
if (stars >= 1000) {
const k = stars / 1000;
return `${k.toFixed(k >= 10 ? 0 : 1)}k`;
}
return stars.toString();
}
export function GithubButton() {
const [stars, setStars] = useState(3_100);
// useEffect(() => {
// getGithubRepoInfo().then((res) => setStars(res.stargazers_count));
// }, []);
return (
<Button variant={'secondary'} asChild>
<Link href="https://git.new/openpanel">
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
{formatStars(stars)} stars
</Link>
</Button>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useIsDarkMode } from '@/lib/dark-mode';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
type Frame = {
id: string;
label: string;
key: string;
Component: React.ComponentType;
};
function LivePreview() {
const isDark = useIsDarkMode();
const colorScheme = isDark ? 'dark' : 'light';
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded) {
return null;
}
return (
<iframe
// src={`http://localhost:3000/share/overview/zef2XC?header=0&range=30d&colorScheme=${colorScheme}`}
src={`https://dashboard.openpanel.dev/share/overview/zef2XC?header=0&range=30d&colorScheme=${colorScheme}`}
className="w-full h-full"
title="Live preview"
scrolling="no"
/>
);
}
function Image({ src }: { src: string }) {
const isDark = useIsDarkMode();
const colorScheme = isDark ? 'dark' : 'light';
return (
<div>
<img
className="w-full h-full"
src={`/${src}-${colorScheme}.png`}
alt={src}
/>
</div>
);
}
export function HeroCarousel() {
const frames: Frame[] = [
{
id: 'overview',
key: 'overview',
label: 'Live preview',
Component: LivePreview,
},
{
id: 'analytics',
key: 'analytics',
label: 'Product analytics',
Component: () => <Image src="dashboard" />,
},
{
id: 'funnels',
key: 'funnels',
label: 'Funnels',
Component: () => <Image src="funnel" />,
},
{
id: 'retention',
key: 'retention',
label: 'Retention',
Component: () => <Image src="retention" />,
},
{
id: 'profile',
key: 'profile',
label: 'Inspect profile',
Component: () => <Image src="profile" />,
},
];
const [activeFrames, setActiveFrames] = useState<Frame[]>([frames[0]]);
const activeFrame = activeFrames[activeFrames.length - 1];
return (
<div className="col gap-6 w-full">
<div className="row gap-4 justify-center [&>div]:font-medium mt-1">
{frames.map((frame) => (
<div key={frame.id} className="relative">
<Button
variant="naked"
type="button"
onClick={() => {
if (activeFrame.id === frame.id) {
return;
}
const newFrame = {
...frame,
key: Math.random().toString().slice(2, 11),
};
setActiveFrames((p) => [...p.slice(-2), newFrame]);
}}
className="relative"
>
{frame.label}
</Button>
<motion.div
className="h-1 bg-foreground rounded-full"
initial={false}
animate={{
width: activeFrame.id === frame.id ? '100%' : '0%',
opacity: activeFrame.id === frame.id ? 1 : 0,
}}
whileHover={{
width: '100%',
opacity: 0.5,
}}
transition={{ duration: 0.2, ease: 'easeInOut' }}
/>
</div>
))}
</div>
<div className="pulled animated-iframe-gradient p-px pb-0 rounded-t-xl">
<div className="overflow-hidden rounded-xl rounded-b-none w-full bg-background">
<div className="relative w-full h-[750px]">
<AnimatePresence mode="popLayout" initial={false}>
{activeFrames.slice(-2).map((frame) => (
<motion.div
key={frame.key}
layout
className="absolute inset-0 w-full h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: 'easeIn' }}
>
<div className="bg-background rounded-xl h-full w-full">
<frame.Component />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { motion, useScroll, useTransform } from 'framer-motion';
import { WorldMap } from './world-map';
export function HeroMap() {
const { scrollY } = useScroll();
const y = useTransform(scrollY, [0, 250], [0, 50], { clamp: true });
const scale = useTransform(scrollY, [0, 250], [1, 1.1], { clamp: true });
return (
<motion.div
style={{ y, scale }}
className="absolute inset-0 top-20 center-center items-start select-none"
>
<div className="min-w-[1400px] w-full">
<WorldMap />
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,66 @@
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { HeroCarousel } from './hero-carousel';
import { HeroMap } from './hero-map';
import { Button } from './ui/button';
import { WorldMap } from './world-map';
export function Hero() {
return (
<HeroContainer>
{/* Shadow bottom */}
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent z-20" />
{/* Content */}
<div className="container relative z-10">
<div className="max-w-2xl col gap-4 pt-28 text-center mx-auto ">
<h1 className="text-6xl font-bold leading-[1.1] animate-fade-up">
An open-source alternative to <span>Mixpanel</span>
</h1>
<p className="text-xl text-muted-foreground animate-fade-up">
The power of Mixpanel, the ease of Plausible and nothing from Google
Analytics 😉
</p>
</div>
{/* CTA */}
<div className="row gap-4 center-center my-12 animate-fade-up">
<Button size="lg" asChild>
<Link href="https://dashboard.openpanel.dev/register">
Try it for free
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Free for 30 days, no credit card required
</p>
</div>
<HeroCarousel />
</div>
</HeroContainer>
);
}
export function HeroContainer({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}): React.ReactElement {
return (
<section className={cn('radial-gradient overflow-hidden relative')}>
{/* Map */}
<HeroMap />
{/* Gradient over map */}
<div className="absolute inset-0 radial-gradient-dot-1 select-none" />
<div className="absolute inset-0 radial-gradient-dot-1 select-none" />
<div className={cn('relative z-10', className)}>{children}</div>
{/* Shadow bottom */}
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
</section>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
export function PlusLine({ className }: { className?: string }) {
return (
<div className={cn('absolute', className)}>
<div className="relative">
<div className="w-px h-8 bg-foreground/40 -bottom-4 left-0 absolute animate-pulse" />
<div className="w-8 h-px bg-foreground/40 -bottom-px -left-4 absolute animate-pulse" />
</div>
</div>
);
}
export function VerticalLine({ className }: { className?: string }) {
return (
<div
className={cn(
'w-px bg-gradient-to-t from-transparent via-foreground/30 to-transparent absolute -top-12 -bottom-12',
className,
)}
/>
);
}
export function HorizontalLine({ className }: { className?: string }) {
return (
<div
className={cn(
'h-px bg-gradient-to-r from-transparent via-foreground/30 to-transparent absolute left-0 right-0',
className,
)}
/>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
export function Logo({ className }: { className?: string }) {
return (
<svg
width="61"
height="35"
viewBox="0 0 61 35"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn('text-black dark:text-white', className)}
>
<rect
x="34.0269"
y="0.368164"
width="10.3474"
height="34.2258"
rx="5.17372"
/>
<rect
x="49.9458"
y="0.368164"
width="10.3474"
height="17.5109"
rx="5.17372"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.212 0C6.36293 0 0 6.36293 0 14.212V20.02C0 27.8691 6.36293 34.232 14.212 34.232C22.0611 34.232 28.424 27.8691 28.424 20.02V14.212C28.424 6.36293 22.0611 0 14.212 0ZM14.2379 8.35999C11.3805 8.35999 9.06419 10.6763 9.06419 13.5337V20.6971C9.06419 23.5545 11.3805 25.8708 14.2379 25.8708C17.0953 25.8708 19.4116 23.5545 19.4116 20.6971V13.5337C19.4116 10.6763 17.0953 8.35999 14.2379 8.35999Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { baseOptions } from '@/app/layout.config';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { MenuIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { GithubButton } from './github-button';
import { Logo } from './logo';
import { Button } from './ui/button';
const Navbar = () => {
const pathname = usePathname();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navbarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
// If click outside of the menu, close it
const handleClick = (e: MouseEvent) => {
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
setIsMobileMenuOpen(false);
}
};
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [isMobileMenuOpen]);
if (pathname.startsWith('/docs')) {
return null;
}
return (
<nav className="fixed top-4 z-50 w-full animate-fade-down" ref={navbarRef}>
<div className="container">
<div
className={cn(
'flex justify-between border border-transparent backdrop-blur-lg items-center p-4 -mx-4 rounded-full transition-colors',
isScrolled
? 'bg-background/90 border-foreground/10'
: 'bg-transparent',
)}
>
{/* Logo */}
<div className="flex-shrink-0">
<Link href="/" className="row items-center font-medium">
<Logo className="h-6" />
{baseOptions.nav?.title}
</Link>
</div>
<div className="row items-center gap-8">
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8 text-sm">
{baseOptions.links?.map((link) => {
if (link.type !== 'main') {
return null;
}
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground font-medium"
>
{link.text}
</Link>
);
})}
</div>
{/* Right side buttons */}
<div className="flex items-center gap-2">
<GithubButton />
{/* Sign in button */}
<Button asChild>
<Link href="https://dashboard.openpanel.dev/login">
Sign in
</Link>
</Button>
<Button
className="md:hidden -my-2"
size="icon"
variant="ghost"
onClick={() => {
setIsMobileMenuOpen((p) => !p);
}}
>
<MenuIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden -mx-4"
>
<div className="rounded-xl bg-background/90 border border-foreground/10 mt-4 md:hidden backdrop-blur-lg">
<div className="col text-sm divide-y divide-foreground/10">
{baseOptions.links?.map((link) => {
if (link.type !== 'main') return null;
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground text-xl font-medium p-4 px-4"
>
{link.text}
</Link>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,10 @@
import { Callout } from 'fumadocs-ui/components/callout';
export function PersonalDataWarning() {
return (
<Callout>
Keep in mind that this is considered personal data. Make sure you have the
users consent before calling this!
</Callout>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import NumberFlow from '@number-flow/react';
import { useState } from 'react';
import { Slider } from './ui/slider';
const PRICING = [
{ price: 0, events: 5_000 },
{ price: 5, events: 10_000 },
{ price: 20, events: 100_000 },
{ price: 30, events: 250_000 },
{ price: 50, events: 500_000 },
{ price: 90, events: 1_000_000 },
{ price: 180, events: 2_500_000 },
{ price: 250, events: 5_000_000 },
{ price: 400, events: 10_000_000 },
// { price: 650, events: 20_000_000 },
// { price: 900, events: 30_000_000 },
];
export function PricingSlider() {
const [index, setIndex] = useState(2);
const match = PRICING[index];
const formatNumber = (value: number) => value.toLocaleString();
return (
<>
<Slider
value={[index]}
max={PRICING.length}
step={1}
tooltip={
match
? `${formatNumber(match.events)} events`
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
}
onValueChange={(value) => setIndex(value[0])}
/>
{match ? (
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}}
locales={'en-US'}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
</div>
) : (
<div>Contact us hello@openpanel.dev</div>
)}
</>
);
}

View File

@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils';
export function Section({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <section className={cn('my-32 col', className)}>{children}</section>;
}
export function SectionHeader({
tag,
title,
description,
className,
}: {
tag?: React.ReactNode;
title: string;
description: string;
className?: string;
}) {
return (
<div className={cn('col gap-4 center-center mb-16', className)}>
{tag}
<h2 className="text-4xl font-medium">{title}</h2>
<p className="text-muted-foreground max-w-screen-sm">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { ShieldQuestionIcon } from 'lucide-react';
import Script from 'next/script';
import { Section, SectionHeader } from '../section';
import { Tag } from '../tag';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
const questions = [
{
question: 'Is OpenPanel free?',
answer: [
'Yes and no, we have a free tier if you send less then 10k events per month, if you need more, you can upgrade to a paid plan.',
'OpenPanel is open-source and free to self-hosting.',
],
},
{
question: 'Is everything really unlimited?',
answer: [
'Everything except the amount of events is unlimited.',
'We do not limit the amount of users, projects, dashboards, etc. We want a transparent and fair pricing model and we think unlimited is the best way to do this.',
],
},
{
question: 'What is the difference between web and product analytics?',
answer: [
'Web analytics focuses on website traffic metrics like page views, bounce rates, and visitor sources. Product analytics goes deeper into user behavior, tracking specific actions, user journeys, and feature usage within your application.',
],
},
{
question: 'Do I need to modify my code to use OpenPanel?',
answer: [
'Minimal setup is required. Simply add our lightweight JavaScript snippet to your website or use one of our SDKs for your preferred framework. Most common frameworks like React, Vue, and Next.js are supported.',
],
},
{
question: 'Is my data GDPR compliant?',
answer: [
'Yes, OpenPanel is fully GDPR compliant. We collect only essential data, do not use cookies for tracking, and provide tools to help you maintain compliance with privacy regulations.',
'You can self-host OpenPanel to keep full control of your data.',
],
},
{
question: 'How does OpenPanel compare to Mixpanel?',
answer: [
'OpenPanel offers most of Mixpanel report features such as funnels, retention and visualizations of your data. If you miss something, please let us know. The biggest difference is that OpenPanel offers better web analytics.',
'Other than that OpenPanel is way cheaper and can also be self-hosted.',
],
},
{
question: 'How does OpenPanel compare to Plausible?',
answer: [
`OpenPanel's web analytics is inspired by Plausible like many other analytics tools. The difference is that OpenPanel offers more tools for product analytics and better support for none web devices (iOS,Android and servers).`,
],
},
{
question: 'How does OpenPanel compare to Google Analytics?',
answer: [
'OpenPanel offers a more privacy-focused, user-friendly alternative to Google Analytics. We provide real-time data, no sampling, and more intuitive product analytics features.',
'Unlike GA4, our interface is designed to be simple yet powerful, making it easier to find the insights you need.',
],
},
{
question: 'Can I export my data?',
answer: [
'Currently you can export your data with our API. Depending on how many events you have this can be an issue.',
'We are working on better export options and will be finished around Q1 2025.',
],
},
{
question: 'What kind of support do you offer?',
answer: ['Currently we offer support through GitHub and Discord.'],
},
];
export default Faq;
export function Faq() {
// Create the JSON-LD structured data
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: questions.map((q) => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: {
'@type': 'Answer',
text: q.answer.join(' '),
},
})),
};
return (
<Section className="container">
{/* Add the JSON-LD script */}
<Script
strategy="beforeInteractive"
id="faq-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<SectionHeader
tag={
<Tag>
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
Get answers today
</Tag>
}
title="FAQ"
description="Some of the most common questions we get asked."
/>
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center"
>
{questions.map((q) => (
<AccordionItem value={q.question} key={q.question}>
<AccordionTrigger>{q.question}</AccordionTrigger>
<AccordionContent>
<div className="max-w-2xl col gap-2">
{q.answer.map((a) => (
<p key={a}>{a}</p>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Section>
);
}

View File

@@ -0,0 +1,235 @@
import {
Feature,
FeatureContent,
FeatureList,
FeatureMore,
} from '@/components/feature';
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import {
AreaChartIcon,
BarChart2Icon,
BarChartIcon,
BatteryIcon,
ClockIcon,
CloudIcon,
ConeIcon,
CookieIcon,
DatabaseIcon,
LineChartIcon,
MapIcon,
PieChartIcon,
UserIcon,
} from 'lucide-react';
import { EventsFeature } from './features/events-feature';
import { ProductAnalyticsFeature } from './features/product-analytics-feature';
import { ProfilesFeature } from './features/profiles-feature';
import { WebAnalyticsFeature } from './features/web-analytics-feature';
export function Features() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
tag={
<Tag>
<BatteryIcon className="size-4" strokeWidth={1.5} />
Batteries included
</Tag>
}
title="Everything you need"
description="We have combined the best features from the most popular analytics tools into one simple to use platform."
/>
<div className="col gap-16">
<Feature media={<WebAnalyticsFeature />}>
<FeatureContent
title="Web analytics"
content={[
'Privacy-friendly analytics with all the important metrics you need, in a simple and modern interface.',
]}
/>
<FeatureList
className="mt-4"
title="Get a quick overview"
items={[
'• Visitors',
'• Referrals',
'• Top pages',
'• Top entries',
'• Top exists',
'• Devices',
'• Sessions',
'• Bounce rate',
'• Duration',
'• Geography',
]}
/>
{/* <FeatureMore href="#" className="mt-4">
And mouch more
</FeatureMore> */}
</Feature>
<Feature reverse media={<ProductAnalyticsFeature />}>
<FeatureContent
title="Product analytics"
content={[
'Turn data into decisions with powerful visualizations and real-time insights.',
]}
/>
<FeatureList
className="mt-4"
title="Supported charts"
items={[
<div className="row items-center gap-2" key="line">
<LineChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Line
</div>,
<div className="row items-center gap-2" key="bar">
<BarChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Bar
</div>,
<div className="row items-center gap-2" key="pie">
<PieChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Pie
</div>,
<div className="row items-center gap-2" key="area">
<AreaChartIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Area
</div>,
<div className="row items-center gap-2" key="histogram">
<BarChart2Icon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Histogram
</div>,
<div className="row items-center gap-2" key="map">
<MapIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Map
</div>,
<div className="row items-center gap-2" key="funnel">
<ConeIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Funnel
</div>,
<div className="row items-center gap-2" key="retention">
<UserIcon
className="size-4 text-foreground/70"
strokeWidth={1.5}
/>{' '}
Retention
</div>,
]}
/>
</Feature>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Feature>
<FeatureContent
icon={<ClockIcon className="size-8" strokeWidth={1} />}
title="Real time analytics"
content={[
'Get instant insights into your data. No need to wait for data to be processed, like other tools out there, looking at you GA4...',
]}
/>
</Feature>
<Feature>
<FeatureContent
icon={<DatabaseIcon className="size-8" strokeWidth={1} />}
title="Own your own data"
content={[
'Own your data, no vendor lock-in. Export your all your data or delete it any time',
]}
/>
</Feature>
<div />
<div />
<Feature>
<FeatureContent
icon={<CloudIcon className="size-8" strokeWidth={1} />}
title="Cloud or self-hosted"
content={[
'We offer a cloud version of the platform, but you can also self-host it on your own infrastructure.',
]}
/>
<FeatureMore href="#" className="mt-4 -mb-4">
More about self-hosting
</FeatureMore>
</Feature>
<Feature>
<FeatureContent
icon={<CookieIcon className="size-8" strokeWidth={1} />}
title="No cookies"
content={[
'We care about your privacy, so our tracker does not use cookies. This keeps your data safe and secure.',
'We follow GDPR guidelines closely, ensuring your personal information is protected without using invasive technologies.',
]}
/>
</Feature>
</div>
<Feature media={<EventsFeature />}>
<FeatureContent
title="Your events"
content={[
'Track every user interaction with powerful real-time event analytics. See all event properties, user actions, and conversion data in one place.',
'From pageviews to custom events, get complete visibility into how users actually use your product.',
]}
/>
<FeatureList
cols={1}
className="mt-4"
title="Some goodies"
items={[
'• Events arrive within seconds',
'• Filter on any property or attribute',
'• Get notified on important events',
'• Export and analyze event data',
'• Track user journeys and conversions',
]}
/>
</Feature>
<Feature reverse media={<ProfilesFeature />}>
<FeatureContent
title="Profiles and sessions"
content={[
'Get detailed insights into how users interact with your product through comprehensive profile and session tracking. See everything from basic metrics to detailed behavioral patterns.',
'Track session duration, page views, and user journeys to understand how people actually use your product.',
]}
/>
<FeatureList
cols={1}
className="mt-4"
title="What can you see?"
items={[
'• First and last seen dates',
'• Session duration and counts',
'• Page views and activity patterns',
'• User location and device info',
'• Browser and OS details',
'• Event history and interactions',
'• Real-time activity tracking',
]}
/>
</Feature>
</div>
</Section>
);
}

View File

@@ -0,0 +1,271 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import {
BellIcon,
BookOpenIcon,
DownloadIcon,
EyeIcon,
HeartIcon,
LogOutIcon,
MessageSquareIcon,
SearchIcon,
SettingsIcon,
Share2Icon,
ShoppingCartIcon,
StarIcon,
ThumbsUpIcon,
UserPlusIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
interface Event {
id: number;
action: string;
location: string;
platform: string;
icon: any;
color: string;
}
const locations = [
'Gothenburg',
'Stockholm',
'Oslo',
'Copenhagen',
'Berlin',
'New York',
'Singapore',
'London',
'Paris',
'Madrid',
'Rome',
'Barcelona',
'Amsterdam',
'Vienna',
];
const platforms = ['iOS', 'Android', 'Windows', 'macOS'];
const browsers = ['WebKit', 'Chrome', 'Firefox', 'Safari'];
const getCountryFlag = (country: (typeof locations)[number]) => {
switch (country) {
case 'Gothenburg':
return '🇸🇪';
case 'Stockholm':
return '🇸🇪';
case 'Oslo':
return '🇳🇴';
case 'Copenhagen':
return '🇩🇰';
case 'Berlin':
return '🇩🇪';
case 'New York':
return '🇺🇸';
case 'Singapore':
return '🇸🇬';
case 'London':
return '🇬🇧';
case 'Paris':
return '🇫🇷';
case 'Madrid':
return '🇪🇸';
case 'Rome':
return '🇮🇹';
case 'Barcelona':
return '🇪🇸';
case 'Amsterdam':
return '🇳🇱';
case 'Vienna':
return '🇦🇹';
}
};
const getPlatformIcon = (platform: (typeof platforms)[number]) => {
switch (platform) {
case 'iOS':
return '🍎';
case 'Android':
return '🤖';
case 'Windows':
return '💻';
case 'macOS':
return '🍎';
}
};
const TOTAL_EVENTS = 10;
export function EventsFeature() {
const [events, setEvents] = useState<Event[]>([
{
id: 1730663803358.4075,
action: 'purchase',
location: 'New York',
platform: 'macOS',
icon: ShoppingCartIcon,
color: 'bg-blue-500',
},
{
id: 1730663801358.3079,
action: 'logout',
location: 'Copenhagen',
platform: 'Windows',
icon: LogOutIcon,
color: 'bg-red-500',
},
{
id: 1730663799358.0283,
action: 'sign up',
location: 'Berlin',
platform: 'Android',
icon: UserPlusIcon,
color: 'bg-green-500',
},
{
id: 1730663797357.2036,
action: 'share',
location: 'Barcelona',
platform: 'macOS',
icon: Share2Icon,
color: 'bg-cyan-500',
},
{
id: 1730663795358.763,
action: 'sign up',
location: 'New York',
platform: 'macOS',
icon: UserPlusIcon,
color: 'bg-green-500',
},
{
id: 1730663792067.689,
action: 'share',
location: 'New York',
platform: 'macOS',
icon: Share2Icon,
color: 'bg-cyan-500',
},
{
id: 1730663790075.3435,
action: 'like',
location: 'Copenhagen',
platform: 'iOS',
icon: HeartIcon,
color: 'bg-pink-500',
},
{
id: 1730663788070.351,
action: 'recommend',
location: 'Oslo',
platform: 'Android',
icon: ThumbsUpIcon,
color: 'bg-orange-500',
},
{
id: 1730663786074.429,
action: 'read',
location: 'New York',
platform: 'Windows',
icon: BookOpenIcon,
color: 'bg-teal-500',
},
{
id: 1730663784065.6309,
action: 'sign up',
location: 'Gothenburg',
platform: 'iOS',
icon: UserPlusIcon,
color: 'bg-green-500',
},
]);
useEffect(() => {
// Prepend new event every 2 seconds
const interval = setInterval(() => {
setEvents((prevEvents) => [
generateEvent(),
...prevEvents.slice(0, TOTAL_EVENTS - 1),
]);
}, 2000);
return () => clearInterval(interval);
}, []);
return (
<div className="overflow-hidden p-8 max-h-[700px]">
<div
className="min-w-[1000px] gap-4 flex flex-col overflow-hidden relative isolate"
// style={{ height: 60 * TOTAL_EVENTS + 16 * (TOTAL_EVENTS - 1) }}
>
<AnimatePresence mode="popLayout" initial={false}>
{events.map((event) => (
<motion.div
key={event.id}
className="flex items-center shadow bg-background-light rounded"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: '60px' }}
exit={{ opacity: 0, height: 0 }}
transition={{
duration: 0.3,
type: 'spring',
stiffness: 500,
damping: 50,
opacity: { duration: 0.2 },
}}
>
<div className="flex items-center gap-2 w-[200px] py-2 px-4">
<div
className={`size-8 rounded-full bg-background flex items-center justify-center ${event.color} text-white `}
>
{event.icon && <event.icon size={16} />}
</div>
<span className="font-medium truncate">{event.action}</span>
</div>
<div className="w-[150px] py-2 px-4 truncate">
<span className="mr-2 text-xl relative top-px">
{getCountryFlag(event.location)}
</span>
{event.location}
</div>
<div className="w-[150px] py-2 px-4 truncate">
<img src={getPlatformIcon(event.platform)} alt="" />
{event.platform}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
// Helper function to generate events (moved outside component)
function generateEvent() {
const actions = [
{ text: 'sign up', icon: UserPlusIcon, color: 'bg-green-500' },
{ text: 'purchase', icon: ShoppingCartIcon, color: 'bg-blue-500' },
{ text: 'screen view', icon: EyeIcon, color: 'bg-purple-500' },
{ text: 'logout', icon: LogOutIcon, color: 'bg-red-500' },
{ text: 'like', icon: HeartIcon, color: 'bg-pink-500' },
{ text: 'comment', icon: MessageSquareIcon, color: 'bg-indigo-500' },
{ text: 'share', icon: Share2Icon, color: 'bg-cyan-500' },
{ text: 'download', icon: DownloadIcon, color: 'bg-emerald-500' },
{ text: 'notification', icon: BellIcon, color: 'bg-violet-500' },
{ text: 'settings', icon: SettingsIcon, color: 'bg-slate-500' },
{ text: 'search', icon: SearchIcon, color: 'bg-violet-500' },
{ text: 'read', icon: BookOpenIcon, color: 'bg-teal-500' },
{ text: 'recommend', icon: ThumbsUpIcon, color: 'bg-orange-500' },
{ text: 'favorite', icon: StarIcon, color: 'bg-yellow-500' },
];
const selectedAction = actions[Math.floor(Math.random() * actions.length)];
return {
id: Date.now() + Math.random(),
action: selectedAction.text,
location: locations[Math.floor(Math.random() * locations.length)],
platform: platforms[Math.floor(Math.random() * platforms.length)],
icon: selectedAction.icon,
color: selectedAction.color,
};
}

View File

@@ -0,0 +1,208 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
// Mock data structure for retention cohort
const COHORT_DATA = [
{
week: 'Week 1',
users: '2,543',
retention: [100, 84, 73, 67, 62, 58],
},
{
week: 'Week 2',
users: '2,148',
retention: [100, 80, 69, 63, 59, 55],
},
{
week: 'Week 3',
users: '1,958',
retention: [100, 82, 71, 64, 60, 56],
},
{
week: 'Week 4',
users: '2,034',
retention: [100, 83, 72, 65, 61, 57],
},
{
week: 'Week 5',
users: '1,987',
retention: [100, 81, 70, 64, 60, 56],
},
{
week: 'Week 6',
users: '2,245',
retention: [100, 85, 74, 68, 64, 60],
},
{
week: 'Week 7',
users: '2,108',
retention: [100, 82, 71, 65, 61, 57],
},
{
week: 'Week 8',
users: '1,896',
retention: [100, 83, 72, 66, 62, 58],
},
{
week: 'Week 9',
users: '2,156',
retention: [100, 81, 70, 64, 60, 56],
},
{ week: 'Week 10', users: '2,089', retention: [100, 84, 73, 67, 63] },
{ week: 'Week 11', users: '1,967', retention: [100, 82, 71, 65] },
{ week: 'Week 12', users: '2,198', retention: [100, 83, 72] },
{ week: 'Week 13', users: '2,045', retention: [100, 81] },
// { week: 'Week 14', users: '1,978', retention: [100, 84, 73] },
// { week: 'Week 15', users: '2,134', retention: [100, 82] },
// { week: 'Week 16', users: '1,923', retention: [100] },
];
const COHORT_DATA_ALT = [
{
week: 'Week 1',
users: '2,876',
retention: [100, 79, 76, 70, 65, 61],
},
{
week: 'Week 2',
users: '2,543',
retention: [100, 85, 73, 67, 62, 58],
},
{
week: 'Week 3',
users: '2,234',
retention: [100, 79, 75, 68, 63, 59],
},
{
week: 'Week 4',
users: '2,456',
retention: [100, 88, 77, 69, 65, 61],
},
{
week: 'Week 5',
users: '2,321',
retention: [100, 77, 73, 67, 54, 42],
},
{
week: 'Week 6',
users: '2,654',
retention: [100, 91, 83, 69, 66, 62],
},
{
week: 'Week 7',
users: '2,432',
retention: [100, 93, 88, 72, 64, 60],
},
{
week: 'Week 8',
users: '2,123',
retention: [100, 78, 76, 69, 65, 61],
},
{
week: 'Week 9',
users: '2,567',
retention: [100, 70, 64, 61, 59, 58],
},
{ week: 'Week 10', users: '2,345', retention: [100, 88, 77, 71, 67] },
{ week: 'Week 11', users: '2,234', retention: [100, 86, 75, 69] },
{ week: 'Week 12', users: '2,543', retention: [100, 79, 76] },
{ week: 'Week 13', users: '2,321', retention: [100, 77] },
// { week: 'Week 14', users: '1,978', retention: [100, 84, 73] },
// { week: 'Week 15', users: '2,134', retention: [100, 82] },
// { week: 'Week 16', users: '1,923', retention: [100] },
];
function RetentionCell({ percentage }: { percentage: number }) {
// Calculate color intensity based on percentage
const getBackgroundColor = (value: number) => {
if (value === 0) return 'bg-transparent';
// Using CSS color mixing to create a gradient from light to dark blue
return `rgb(${Math.round(239 - value * 1.39)} ${Math.round(246 - value * 1.46)} ${Math.round(255 - value * 0.55)})`;
};
return (
<div className="flex items-center justify-center p-px text-sm font-medium w-[80px]">
<div
className="flex text-white items-center justify-center w-full h-full rounded"
style={{
backgroundColor: getBackgroundColor(percentage),
}}
>
<motion.span
key={percentage}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{percentage}%
</motion.span>
</div>
</div>
);
}
export function ProductAnalyticsFeature() {
const [currentData, setCurrentData] = useState(COHORT_DATA);
useEffect(() => {
const interval = setInterval(() => {
setCurrentData((current) =>
current === COHORT_DATA ? COHORT_DATA_ALT : COHORT_DATA,
);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-4 w-full overflow-hidden">
<div className="flex">
{/* Header row */}
<div className="min-w-[70px] flex flex-col">
<div className="p-2 font-medium text-xs text-muted-foreground">
Cohort
</div>
</div>
{/* Week numbers - changed length to 6 */}
<div className="flex">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i.toString()}
className="text-muted-foreground w-[80px] text-xs text-center p-2 font-medium"
>
W{i + 1}
</div>
))}
</div>
</div>
{/* Data rows */}
<div className="flex flex-col">
{currentData.map((cohort, rowIndex) => (
<div key={rowIndex.toString()} className="flex">
<div className="min-w-[70px] flex flex-col">
<div className="p-2 text-sm whitespace-nowrap text-muted-foreground">
{cohort.week}
</div>
</div>
<div className="flex">
{cohort.retention.map((value, cellIndex) => (
<RetentionCell key={cellIndex.toString()} percentage={value} />
))}
{/* Fill empty cells - changed length to 6 */}
{Array.from({ length: 6 - cohort.retention.length }).map(
(_, i) => (
<div key={`empty-${i.toString()}`} className="w-[80px] p-px">
<div className="h-full w-full rounded bg-background" />
</div>
),
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState } from 'react';
const PROFILES = [
{
name: 'Joe Bloggs',
email: 'joe@bloggs.com',
avatar: '/avatar.jpg',
stats: {
firstSeen: 'about 2 months',
lastSeen: '41 minutes',
sessions: '8',
avgSession: '5m 59s',
p90Session: '7m 42s',
pageViews: '41',
},
},
{
name: 'Jane Smith',
email: 'jane@smith.com',
avatar: '/avatar-2.jpg',
stats: {
firstSeen: 'about 1 month',
lastSeen: '2 hours',
sessions: '12',
avgSession: '4m 32s',
p90Session: '6m 15s',
pageViews: '35',
},
},
{
name: 'Alex Johnson',
email: 'alex@johnson.com',
avatar: '/avatar-3.jpg',
stats: {
firstSeen: 'about 3 months',
lastSeen: '15 minutes',
sessions: '15',
avgSession: '6m 20s',
p90Session: '8m 10s',
pageViews: '52',
},
},
];
export function ProfilesFeature() {
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(true);
useEffect(() => {
const timer = setInterval(() => {
if (currentIndex === PROFILES.length) {
setIsTransitioning(false);
setCurrentIndex(0);
setTimeout(() => setIsTransitioning(true), 50);
} else {
setCurrentIndex((current) => current + 1);
}
}, 3000);
return () => clearInterval(timer);
}, [currentIndex]);
return (
<div className="overflow-hidden">
<div
className={`flex ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{[...PROFILES, PROFILES[0]].map((profile, index) => (
<div
key={profile.name + index.toString()}
className="w-full flex-shrink-0 p-8"
>
<div className="row items-center gap-4">
<img src={profile.avatar} className="size-32 rounded-full" />
<div>
<div className="text-3xl font-semibold">{profile.name}</div>
<div className="text-muted-foreground">{profile.email}</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">First seen</div>
<div className="text-lg font-medium">
{profile.stats.firstSeen}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Last seen</div>
<div className="text-lg font-medium">
{profile.stats.lastSeen}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Sessions</div>
<div className="text-lg font-medium">
{profile.stats.sessions}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">
Avg. Session
</div>
<div className="text-lg font-medium">
{profile.stats.avgSession}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">
P90. Session
</div>
<div className="text-lg font-medium">
{profile.stats.p90Session}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Page views</div>
<div className="text-lg font-medium">
{profile.stats.pageViews}
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'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 { 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 WebAnalyticsFeature() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [currentCountryIndex, setCurrentCountryIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
setCurrentCountryIndex((prev) => (prev + 1) % COUNTRIES.length);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-8 relative col gap-4">
<div className="relative">
<MetricCard
title="Session duration"
value="3m 23s"
change="3%"
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="hsl(var(--red))"
className="w-full rotate-3 -left-2 hover:-translate-y-1 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="hsl(var(--green))"
className="w-full -mt-8 -rotate-2 left-2 top-14 hover:-translate-y-1 transition-all duration-300"
/>
</div>
<div>
<div className="-rotate-2 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...TRAFFIC_SOURCES[currentSourceIndex]} />
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
/>
</div>
<div className="rotate-1 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...COUNTRIES[currentCountryIndex]} />
<BarCell
{...COUNTRIES[(currentCountryIndex + 1) % COUNTRIES.length]}
/>
</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(
'row items-end bg-background-light rounded-lg p-4 pb-6 border justify-between',
className,
)}
>
<div>
<div className="text-muted-foreground text-xl">{title}</div>
<div className="text-5xl font-bold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium text-lg">
<div
className="size-6 rounded-full flex items-center justify-center"
style={{
background: color,
}}
>
<ArrowUpIcon className="size-4" strokeWidth={3} />
</div>
<div>{change}</div>
</div>
<SimpleChart
width={500}
height={30}
points={chartPoints}
className="absolute bottom-0 left-0 right-0"
strokeColor={color}
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
}: {
icon: string;
name: string;
percentage: number;
value: number;
}) {
return (
<div className="relative p-2">
<div
className="absolute bg-background-dark 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">
{icon.startsWith('http') ? (
<img
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
/>
) : (
<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">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span>
<NumberFlow value={value} locales={'en-US'} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { CheckIcon, DollarSignIcon } from 'lucide-react';
import Link from 'next/link';
import { DoubleSwirl } from '../Swirls';
import { PricingSlider } from '../pricing-slider';
import { Section, SectionHeader } from '../section';
import { Tag } from '../tag';
import { Button } from '../ui/button';
export default Pricing;
export function Pricing() {
return (
<Section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
<DoubleSwirl className="absolute -top-32 left-0" />
<div className="container relative z-10">
<SectionHeader
tag={
<Tag variant={'dark'}>
<DollarSignIcon className="size-4" />
Simple and predictable
</Tag>
}
title="Simple pricing"
description="Our simple, usage-based pricing means you only pay for what you use. Scale effortlessly for the best value."
/>
<div className="grid md:grid-cols-[400px_1fr] gap-8">
<div className="col gap-4">
<h3 className="font-medium text-xl text-background/90 dark:text-foreground/90">
Stop overpaying <br />
for features
</h3>
<ul className="gap-1 col text-background/70 dark:text-foreground/70">
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />
Unlimited websites or apps
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited users
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited dashboards
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited charts
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited tracked profiles
</li>
</ul>
<Button variant="secondary" className="self-start mt-4" asChild>
<Link href="https://dashboard.openpanel.dev/register">
Start for free
</Link>
</Button>
</div>
<div className="col justify-between pt-14">
<PricingSlider />
<div className="text-sm text-muted-foreground">
<strong className="text-background/80 dark:text-foreground/80">
All features are included upfront - no hidden costs.
</strong>{' '}
You choose how many events to track each month. During the beta
phase, everything is offered for free to users.
</div>
</div>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,86 @@
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import { type Framework, frameworks } from '@openpanel/sdk-info';
import { CodeIcon, ShieldQuestionIcon } from 'lucide-react';
import Link from 'next/link';
import { HorizontalLine, PlusLine, VerticalLine } from '../line';
import { Button } from '../ui/button';
export function Sdks() {
return (
<Section className="container overflow-hidden">
<SectionHeader
tag={
<Tag>
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
Easy to use
</Tag>
}
title="SDKs"
description="Use our modules to integrate with your favourite framework and start collecting events with ease. Enjoy quick and seamless setup."
/>
<div className="col gap-16">
<div className="relative">
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
{frameworks.slice(0, 5).map((sdk, index) => (
<SdkCard key={sdk.name} sdk={sdk} index={index} />
))}
</div>
<HorizontalLine className="opacity-40 -left-32 -right-32" />
</div>
<div className="relative">
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
{frameworks.slice(5, 10).map((sdk, index) => (
<SdkCard key={sdk.name} sdk={sdk} index={index} />
))}
</div>
<HorizontalLine className="opacity-40 -left-32 -right-32" />
</div>
<div className="center-center gap-2 col">
<h3 className="text-muted-foreground text-sm">And many more!</h3>
<Button asChild>
<Link href="/docs">Read our docs</Link>
</Button>
</div>
</div>
</Section>
);
}
function SdkCard({
sdk,
index,
}: {
sdk: Framework;
index: number;
}) {
return (
<Link
key={sdk.name}
href={sdk.href}
className="group relative z-10 col gap-2 uppercase center-center aspect-video bg-background-light rounded-lg shadow-[inset_0_0_0_1px_theme(colors.border),0_0_30px_0px_hsl(var(--border)/0.5)] transition-all hover:scale-105 hover:bg-background-dark"
>
{index === 0 && <PlusLine className="opacity-30 top-0 left-0" />}
{index === 2 && <PlusLine className="opacity-80 bottom-0 right-0" />}
<VerticalLine className="left-0 opacity-40" />
<VerticalLine className="right-0 opacity-40" />
<div className="absolute inset-0 center-center overflow-hidden opacity-20">
<sdk.IconComponent className="size-32 top-[33%] relative group-hover:top-[30%] group-hover:scale-105 transition-all" />
</div>
<div
className="center-center gap-1 col w-full h-full relative rounded-lg"
style={{
background:
'radial-gradient(circle, hsl(var(--background)) 0%, hsl(var(--background)/0.7) 100%)',
}}
>
<sdk.IconComponent className="size-8" />
{/* <h4 className="text-muted-foreground text-[10px]">{sdk.name}</h4> */}
</div>
</Link>
);
}

View File

@@ -0,0 +1,111 @@
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import { cacheable } from '@openpanel/redis';
import Link from 'next/link';
import { Suspense } from 'react';
import { VerticalLine } from '../line';
import { PlusLine } from '../line';
import { HorizontalLine } from '../line';
import { Section } from '../section';
import { Button } from '../ui/button';
import { WorldMap } from '../world-map';
function shortNumber(num: number) {
if (num < 1e3) return num;
if (num >= 1e3 && num < 1e6) return `${+(num / 1e3).toFixed(1)}K`;
if (num >= 1e6 && num < 1e9) return `${+(num / 1e6).toFixed(1)}M`;
if (num >= 1e9 && num < 1e12) return `${+(num / 1e9).toFixed(1)}B`;
if (num >= 1e12) return `${+(num / 1e12).toFixed(1)}T`;
}
const getProjectsWithCount = cacheable(async function getProjectsWithCount() {
const projects = await chQuery<{ project_id: string; count: number }>(
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`,
);
const last24h = await chQuery<{ count: number }>(
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
);
return { projects, last24hCount: last24h[0]?.count || 0 };
}, 60 * 60);
export default Stats;
export function Stats() {
return (
<Suspense
fallback={<StatsPure projectCount={0} eventCount={0} last24hCount={0} />}
>
<StatsServer />
</Suspense>
);
}
export async function StatsServer() {
const { projects, last24hCount } = await getProjectsWithCount();
const projectCount = projects.length;
const eventCount = projects.reduce((acc, { count }) => acc + count, 0);
return (
<StatsPure
projectCount={projectCount}
eventCount={eventCount}
last24hCount={last24hCount}
/>
);
}
export function StatsPure({
projectCount,
eventCount,
last24hCount,
}: { projectCount: number; eventCount: number; last24hCount: number }) {
return (
<Section className="bg-gradient-to-b from-background via-background-dark to-background-dark py-64 pt-44 relative overflow-hidden -mt-16">
{/* Map */}
<div className="absolute inset-0 -top-20 center-center items-start select-none opacity-10">
<div className="min-w-[1400px] w-full">
<WorldMap />
{/* Gradient over Map */}
<div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
</div>
</div>
<div className="relative">
<HorizontalLine />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 container center-center">
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<PlusLine className="hidden lg:block top-0 left-0" />
<div className="text-muted-foreground text-xs">Active projects</div>
<div className="text-5xl font-bold font-mono">{projectCount}</div>
</div>
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<div className="text-muted-foreground text-xs">Total events</div>
<div className="text-5xl font-bold font-mono">
{shortNumber(eventCount)}
</div>
</div>
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<VerticalLine className="hidden lg:block right-0" />
<PlusLine className="hidden lg:block bottom-0 left-0" />
<div className="text-muted-foreground text-xs">
Events last 24 h
</div>
<div className="text-5xl font-bold font-mono">
{shortNumber(last24hCount)}
</div>
</div>
</div>
<HorizontalLine />
</div>
<div className="center-center col gap-4 absolute bottom-20 left-0 right-0 z-10">
<p>Get the analytics you deserve</p>
<Button asChild>
<Link href="https://dashboard.openpanel.dev/register">
Try it for free
</Link>
</Button>
</div>
</Section>
);
}

View File

@@ -0,0 +1,119 @@
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import { TwitterCard } from '@/components/twitter-card';
import { MessageCircleIcon } from 'lucide-react';
const testimonials = [
{
verified: true,
avatarUrl:
'https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_x96.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 its already tracking 750K+ events 🤩',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl:
'https://pbs.twimg.com/profile_images/1755611130368770048/JwLEqyeo_x96.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:
'https://pbs.twimg.com/profile_images/1849912160593268736/Zm3zrpOI_x96.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:
'https://pbs.twimg.com/profile_images/1825857658017959936/3nEG8n7__x96.jpg',
name: 'greg hodson 🍜',
handle: 'h0dson',
content: ['i second this, openpanel is killing it'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl:
'https://pbs.twimg.com/profile_images/1777870199515164672/47EBkHLm_x96.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:
'https://pbs.twimg.com/profile_images/1787577276646780929/YuoDbD1f_x96.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 default Testimonials;
export function Testimonials() {
return (
<Section className="container">
<SectionHeader
tag={
<Tag>
<MessageCircleIcon className="size-4" strokeWidth={1.5} />
Testimonials
</Tag>
}
title="What people say"
description="What our customers say about us."
/>
<div className="col md:row gap-4">
<div className="col gap-4 flex-1">
{testimonials.slice(0, testimonials.length / 2).map((testimonial) => (
<TwitterCard key={testimonial.handle} {...testimonial} />
))}
</div>
<div className="col gap-4 flex-1">
{testimonials.slice(testimonials.length / 2).map((testimonial) => (
<TwitterCard key={testimonial.handle} {...testimonial} />
))}
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,70 @@
import { useMemo } from 'react';
interface SimpleChartProps {
width?: number;
height?: number;
points?: number[];
strokeWidth?: number;
strokeColor?: string;
className?: string;
}
export function SimpleChart({
width = 300,
height = 100,
points = [0, 10, 5, 8, 12, 4, 7],
strokeWidth = 2,
strokeColor = '#2563eb',
className,
}: SimpleChartProps) {
// Skip if no points
if (!points.length) return null;
// Calculate scaling factors
const maxValue = Math.max(...points);
const xStep = width / (points.length - 1);
const yScale = height / maxValue;
// Generate path commands
const pathCommands = points
.map((point, index) => {
const x = index * xStep;
const y = height - point * yScale;
return `${index === 0 ? 'M' : 'L'} ${x},${y}`;
})
.join(' ');
// Create area path by adding bottom corners
const areaPath = `${pathCommands} L ${width},${height} L 0,${height} Z`;
// Generate unique gradient ID
const gradientId = `gradient-${strokeColor
.replace('#', '')
.replaceAll('(', '')
.replaceAll(')', '')}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={`w-full ${className ?? ''}`}
>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
</linearGradient>
</defs>
{/* Area fill */}
<path d={areaPath} fill={`url(#${gradientId})`} />
{/* Stroke line */}
<path
d={pathCommands}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';
import { type VariantProps, cva } from 'class-variance-authority';
const tagVariants = cva(
'shadow-sm px-4 gap-2 center-center border self-auto text-xs rounded-full h-7',
{
variants: {
variant: {
light:
'bg-background-light dark:bg-background-dark text-muted-foreground',
dark: 'bg-foreground-light dark:bg-foreground-dark text-muted border-background/10 shadow-background/5',
},
},
defaultVariants: {
variant: 'light',
},
},
);
interface TagProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof tagVariants> {}
export function Tag({ children, className, variant, ...props }: TagProps) {
return (
<span className={cn(tagVariants({ variant, className }))} {...props}>
{children}
</span>
);
}

View File

@@ -0,0 +1,34 @@
import type { TableOfContents } from 'fumadocs-core/server';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import type React from 'react';
interface Props {
toc: TableOfContents;
}
export const Toc: React.FC<Props> = ({ toc }) => {
return (
<nav className="bg-background-light border rounded-lg pb-2 min-w-[280px]">
<span className="block font-medium p-4 pb-2">Table of contents</span>
<ul>
{toc.map((item) => (
<li
key={item.url}
style={{ marginLeft: `${(item.depth - 2) * (4 * 4)}px` }}
className="p-2 px-4"
>
<Link
href={item.url}
className="hover:underline row gap-2 items-center group"
title={item.title?.toString() ?? ''}
>
<ArrowRightIcon className="shrink-0 w-4 h-4 opacity-30 group-hover:opacity-100 transition-opacity" />
<span className="truncate text-sm">{item.title}</span>
</Link>
</li>
))}
</ul>
</nav>
);
};

View File

@@ -0,0 +1,88 @@
import {
BadgeIcon,
CheckCheckIcon,
CheckIcon,
HeartIcon,
MessageCircleIcon,
RefreshCwIcon,
} from 'lucide-react';
import Image from 'next/image';
interface TwitterCardProps {
avatarUrl?: string;
name: string;
handle: string;
content: React.ReactNode;
replies?: number;
retweets?: number;
likes?: number;
verified?: boolean;
}
export function TwitterCard({
avatarUrl,
name,
handle,
content,
replies = 0,
retweets = 0,
likes = 0,
verified = false,
}: TwitterCardProps) {
const renderContent = () => {
if (typeof content === 'string') {
return <p className="text-muted-foreground">{content}</p>;
}
if (Array.isArray(content) && typeof content[0] === 'string') {
return content.map((line) => <p key={line}>{line}</p>);
}
return content;
};
return (
<div className="border rounded-lg p-4 col gap-4 bg-background-light">
<div className="row gap-4">
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
{avatarUrl && (
<img src={avatarUrl} alt={name} width={48} height={48} />
)}
</div>
<div className="col gap-1">
<div className="col">
<div className="row gap-2 items-center">
<span className="font-medium">{name}</span>
{verified && (
<div className="relative">
<BadgeIcon className="size-4 fill-[#1D9BF0] text-[#1D9BF0]" />
<div className="absolute inset-0 center-center">
<CheckIcon className="size-2 text-white" strokeWidth={3} />
</div>
</div>
)}
</div>
<span className="text-muted-foreground text-sm leading-0">
@{handle}
</span>
</div>
{renderContent()}
<div className="row gap-4 text-muted-foreground text-sm mt-4">
<div className="row gap-2">
<MessageCircleIcon className="transition-all size-4 fill-background hover:fill-blue-500 hover:text-blue-500" />
{/* <span>{replies}</span> */}
</div>
<div className="row gap-2">
<RefreshCwIcon className="transition-all size-4 fill-background hover:text-emerald-500" />
{/* <span>{retweets}</span> */}
</div>
<div className="row gap-2">
<HeartIcon className="transition-all size-4 fill-background hover:fill-rose-500 hover:text-rose-500" />
{/* <span>{likes}</span> */}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item>>;
}) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Trigger>>;
}) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Content>>;
}) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
);
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,65 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'hover:-translate-y-[1px] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-[0_1px_0_0,0_-1px_0_0] shadow-foreground/10',
{
variants: {
variant: {
default: 'bg-foreground text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
naked:
'bg-transparent hover:bg-transparent ring-0 border-none !px-0 !py-0 shadow-none',
},
size: {
default: 'h-8 px-4',
sm: 'h-6 px-2',
lg: 'h-12 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = ({
ref,
className,
variant,
size,
asChild = false,
...props
}: ButtonProps & {
ref?: React.RefObject<HTMLButtonElement>;
}) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
};
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,46 @@
'use client';
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
const Slider = (
{
ref,
className,
tooltip,
...props
}
) => (<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-white/90" />
</SliderPrimitive.Track>
{tooltip ? (
<Tooltip open disableHoverableContent>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={10}
className="rounded-full bg-black text-white/70 py-1 text-xs border-white/30"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
)}
</SliderPrimitive.Root>);
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,34 @@
'use client';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof TooltipPrimitive.Content>>;
}) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,6 @@
##### Web options
- `trackScreenViews` - If true, the library will automatically track screen views (default: false)
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)

View File

@@ -0,0 +1,8 @@
const getMapJSON = require('dotted-map').getMapJSON;
// This function accepts the same arguments as DottedMap in the example above.
export const mapJsonString = getMapJSON({
height: 90,
grid: 'vertical',
avoidOuterPins: true,
});

View File

@@ -0,0 +1,138 @@
'use client';
import DottedMap from 'dotted-map/without-countries';
import { useEffect, useMemo, useState } from 'react';
import { mapJsonString } from './world-map-string';
// Static coordinates list with 50 points
const COORDINATES = [
// Western Hemisphere (Focused on West Coast)
{ lat: 47.6062, lng: -122.3321 }, // Seattle, USA
{ lat: 45.5155, lng: -122.6789 }, // Portland, USA
{ lat: 37.7749, lng: -122.4194 }, // San Francisco, USA
{ lat: 34.0522, lng: -118.2437 }, // Los Angeles, USA
{ lat: 32.7157, lng: -117.1611 }, // San Diego, USA
{ lat: 49.2827, lng: -123.1207 }, // Vancouver, Canada
{ lat: 58.3019, lng: -134.4197 }, // Juneau, Alaska
{ lat: 61.2181, lng: -149.9003 }, // Anchorage, Alaska
{ lat: 64.8378, lng: -147.7164 }, // Fairbanks, Alaska
{ lat: 71.2906, lng: -156.7886 }, // Utqiaġvik (Barrow), Alaska
{ lat: 60.5544, lng: -151.2583 }, // Kenai, Alaska
{ lat: 61.5815, lng: -149.444 }, // Wasilla, Alaska
{ lat: 66.1666, lng: -153.3707 }, // Bettles, Alaska
{ lat: 63.8659, lng: -145.637 }, // Delta Junction, Alaska
{ lat: 55.3422, lng: -131.6461 }, // Ketchikan, Alaska
// Eastern Hemisphere (Focused on East Asia)
{ lat: 35.6762, lng: 139.6503 }, // Tokyo, Japan
{ lat: 43.0621, lng: 141.3544 }, // Sapporo, Japan
{ lat: 26.2286, lng: 127.6809 }, // Naha, Japan
{ lat: 31.2304, lng: 121.4737 }, // Shanghai, China
{ lat: 22.3193, lng: 114.1694 }, // Hong Kong
{ lat: 37.5665, lng: 126.978 }, // Seoul, South Korea
{ lat: 25.033, lng: 121.5654 }, // Taipei, Taiwan
// Russian Far East
{ lat: 64.7336, lng: 177.5169 }, // Anadyr, Russia
{ lat: 59.5613, lng: 150.8086 }, // Magadan, Russia
{ lat: 43.1332, lng: 131.9113 }, // Vladivostok, Russia
{ lat: 53.0444, lng: 158.6478 }, // Petropavlovsk-Kamchatsky, Russia
{ lat: 62.0355, lng: 129.6755 }, // Yakutsk, Russia
{ lat: 48.4827, lng: 135.0846 }, // Khabarovsk, Russia
{ lat: 46.9589, lng: 142.7319 }, // Yuzhno-Sakhalinsk, Russia
{ lat: 52.9651, lng: 158.2728 }, // Yelizovo, Russia
{ lat: 56.1304, lng: 101.614 }, // Bratsk, Russia
// Australia & New Zealand (Main Cities)
{ lat: -33.8688, lng: 151.2093 }, // Sydney, Australia
{ lat: -37.8136, lng: 144.9631 }, // Melbourne, Australia
{ lat: -27.4698, lng: 153.0251 }, // Brisbane, Australia
{ lat: -31.9505, lng: 115.8605 }, // Perth, Australia
{ lat: -12.4634, lng: 130.8456 }, // Darwin, Australia
{ lat: -34.9285, lng: 138.6007 }, // Adelaide, Australia
{ lat: -42.8821, lng: 147.3272 }, // Hobart, Australia
{ lat: -16.9186, lng: 145.7781 }, // Cairns, Australia
{ lat: -23.7041, lng: 133.8814 }, // Alice Springs, Australia
{ lat: -41.2865, lng: 174.7762 }, // Wellington, New Zealand
{ lat: -36.8485, lng: 174.7633 }, // Auckland, New Zealand
{ lat: -43.532, lng: 172.6306 }, // Christchurch, New Zealand
];
export function WorldMap() {
const [visiblePins, setVisiblePins] = useState<typeof COORDINATES>([]);
const activePinColor = '#2265EC';
const inactivePinColor = '#818181';
const visiblePinsCount = 20;
// Helper function to get random coordinates
const getRandomCoordinates = (count: number) => {
const shuffled = [...COORDINATES].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
// Helper function to update pins
const updatePins = () => {
setVisiblePins((current) => {
const newPins = [...current];
// Remove 2 random pins
const pinsToAdd = 4;
if (newPins.length >= pinsToAdd) {
for (let i = 0; i < pinsToAdd; i++) {
const randomIndex = Math.floor(Math.random() * newPins.length);
newPins.splice(randomIndex, 1);
}
}
// Add 2 new random pins from the main coordinates
const availablePins = COORDINATES.filter(
(coord) =>
!newPins.some(
(pin) => pin.lat === coord.lat && pin.lng === coord.lng,
),
);
const newRandomPins = availablePins
.sort(() => 0.5 - Math.random())
.slice(0, pinsToAdd);
return [...newPins, ...newRandomPins].slice(0, visiblePinsCount);
});
};
useEffect(() => {
// Initial pins
setVisiblePins(getRandomCoordinates(10));
// Update pins every 4 seconds
const interval = setInterval(updatePins, 2000);
return () => clearInterval(interval);
}, []);
const map = useMemo(() => {
const map = new DottedMap({ map: JSON.parse(mapJsonString) });
visiblePins.forEach((coord) => {
map.addPin({
lat: coord.lat,
lng: coord.lng,
svgOptions: { color: activePinColor, radius: 0.3 },
});
});
return map.getSVG({
radius: 0.2,
color: inactivePinColor,
shape: 'circle',
});
}, [visiblePins]);
return (
<div>
<img
loading="lazy"
alt="World map with active users"
src={`data:image/svg+xml;utf8,${encodeURIComponent(map)}`}
className="object-contain w-full h-full"
width={1200}
height={630}
/>
</div>
);
}