feature(public,docs): new public website and docs
This commit is contained in:
152
apps/public/components/Swirls.tsx
Normal file
152
apps/public/components/Swirls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/public/components/common-sdk-config.mdx
Normal file
8
apps/public/components/common-sdk-config.mdx
Normal 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
|
||||
12
apps/public/components/device-id-warning.tsx
Normal file
12
apps/public/components/device-id-warning.tsx
Normal 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's up to you.**
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
125
apps/public/components/feature.tsx
Normal file
125
apps/public/components/feature.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/public/components/figure.tsx
Normal file
22
apps/public/components/figure.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/public/components/footer.tsx
Normal file
144
apps/public/components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/public/components/github-button.tsx
Normal file
39
apps/public/components/github-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
apps/public/components/hero-carousel.tsx
Normal file
156
apps/public/components/hero-carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/public/components/hero-map.tsx
Normal file
22
apps/public/components/hero-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/public/components/hero.tsx
Normal file
66
apps/public/components/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/public/components/line.tsx
Normal file
34
apps/public/components/line.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
apps/public/components/logo.tsx
Normal file
34
apps/public/components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/public/components/navbar.tsx
Normal file
138
apps/public/components/navbar.tsx
Normal 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;
|
||||
10
apps/public/components/personal-data-warning.tsx
Normal file
10
apps/public/components/personal-data-warning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/public/components/pricing-slider.tsx
Normal file
59
apps/public/components/pricing-slider.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/public/components/section.tsx
Normal file
31
apps/public/components/section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
apps/public/components/sections/faq.tsx
Normal file
136
apps/public/components/sections/faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
apps/public/components/sections/features.tsx
Normal file
235
apps/public/components/sections/features.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
apps/public/components/sections/features/events-feature.tsx
Normal file
271
apps/public/components/sections/features/events-feature.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
131
apps/public/components/sections/features/profiles-feature.tsx
Normal file
131
apps/public/components/sections/features/profiles-feature.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
77
apps/public/components/sections/pricing.tsx
Normal file
77
apps/public/components/sections/pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/public/components/sections/sdks.tsx
Normal file
86
apps/public/components/sections/sdks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
apps/public/components/sections/stats.tsx
Normal file
111
apps/public/components/sections/stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
apps/public/components/sections/testimonials.tsx
Normal file
119
apps/public/components/sections/testimonials.tsx
Normal 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 it’s 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>
|
||||
);
|
||||
}
|
||||
70
apps/public/components/simple-chart.tsx
Normal file
70
apps/public/components/simple-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/public/components/tag.tsx
Normal file
30
apps/public/components/tag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/public/components/toc.tsx
Normal file
34
apps/public/components/toc.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
apps/public/components/twitter-card.tsx
Normal file
88
apps/public/components/twitter-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/public/components/ui/accordion.tsx
Normal file
69
apps/public/components/ui/accordion.tsx
Normal 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 };
|
||||
65
apps/public/components/ui/button.tsx
Normal file
65
apps/public/components/ui/button.tsx
Normal 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 };
|
||||
46
apps/public/components/ui/slider.tsx
Normal file
46
apps/public/components/ui/slider.tsx
Normal 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 };
|
||||
34
apps/public/components/ui/tooltip.tsx
Normal file
34
apps/public/components/ui/tooltip.tsx
Normal 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 };
|
||||
6
apps/public/components/web-sdk-config.mdx
Normal file
6
apps/public/components/web-sdk-config.mdx
Normal 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)
|
||||
|
||||
8
apps/public/components/world-map-string.ts
Normal file
8
apps/public/components/world-map-string.ts
Normal 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,
|
||||
});
|
||||
138
apps/public/components/world-map.tsx
Normal file
138
apps/public/components/world-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user