update public web

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-06 22:26:31 +01:00
parent 473810984c
commit 41ca38c841
11 changed files with 523 additions and 367 deletions

View File

@@ -34,11 +34,8 @@ const images = [
{ title: 'The classic pie chart', url: '/demo/pie-min.png' }, { title: 'The classic pie chart', url: '/demo/pie-min.png' },
]; ];
export function HomeCarousel() { export function PreviewCarousel() {
return ( return (
<div className="mx-auto max-w-6xl p-4">
<div className="relative">
<div className="rounded-lg w-full max-w-6xl aspect-video dashed absolute -left-5 -top-5"></div>
<Carousel <Carousel
className="w-full" className="w-full"
opts={{ loop: true }} opts={{ loop: true }}
@@ -50,23 +47,23 @@ export function HomeCarousel() {
> >
<CarouselContent> <CarouselContent>
{images.map((item) => ( {images.map((item) => (
<CarouselItem key={item.url}> <CarouselItem key={item.url} className="flex-[0_0_80%] pl-8">
<div className="aspect-video rounded-md overflow-hidden"> <div className="aspect-video">
<div className="p-3 bg-white/20 rounded-xl overflow-hidden">
<Image <Image
className="w-full h-full object-cover" className="w-full h-full object-cover rounded-lg"
src={item.url} src={item.url}
width={1080} width={1080}
height={608} height={608}
alt={item.title} alt={item.title}
/> />
</div> </div>
</div>
</CarouselItem> </CarouselItem>
))} ))}
</CarouselContent> </CarouselContent>
<CarouselPrevious className="hidden md:visible" /> <CarouselPrevious className="hidden md:visible" />
<CarouselNext className="hidden md:visible" /> <CarouselNext className="hidden md:visible" />
</Carousel> </Carousel>
</div>
</div>
); );
} }

View File

@@ -25,7 +25,9 @@ export function Paragraph({ children, className }: Props) {
export function Heading1({ children, className }: Props) { export function Heading1({ children, className }: Props) {
return ( return (
<h1 className={cn('text-4xl md:text-6xl font-bold', className)}> <h1
className={cn('text-5xl md:text-6xl font-bold text-slate-800', className)}
>
{children} {children}
</h1> </h1>
); );
@@ -33,8 +35,20 @@ export function Heading1({ children, className }: Props) {
export function Heading2({ children, className }: Props) { export function Heading2({ children, className }: Props) {
return ( return (
<h2 className={cn('text-2xl md:text-4xl font-bold', className)}> <h2
className={cn('text-4xl md:text-5xl font-bold text-slate-800', className)}
>
{children} {children}
</h2> </h2>
); );
} }
export function Heading3({ children, className }: Props) {
return (
<h3
className={cn('text-2xl md:text-3xl font-bold text-slate-800', className)}
>
{children}
</h3>
);
}

View File

@@ -0,0 +1,79 @@
// background-image: radial-gradient(circle at 1px 1px, black 1px, transparent 0);
// background-size: 40px 40px;
import { Logo } from '@/components/Logo';
import {
BarChart2Icon,
CookieIcon,
Globe2Icon,
LayoutPanelTopIcon,
LockIcon,
} from 'lucide-react';
import { Heading1, Lead, Lead2 } from './copy';
import { JoinWaitlist } from './join-waitlist';
const features = [
{
title: 'Great overview',
icon: LayoutPanelTopIcon,
},
{
title: 'Beautiful charts',
icon: BarChart2Icon,
},
{
title: 'Privacy focused',
icon: LockIcon,
},
{
title: 'Open-source',
icon: Globe2Icon,
},
{
title: 'No cookies',
icon: CookieIcon,
},
];
export function Hero() {
return (
<div>
<div className="absolute top-0 left-0 right-0 py-6">
<div className="container">
<div className="flex justify-between">
<Logo />
</div>
</div>
</div>
<div
className="flex flex-col items-center w-full text-center text-blue-950 bg-[radial-gradient(circle_at_2px_2px,#D9DEF6_2px,transparent_0)]"
style={{
backgroundSize: '70px 70px',
}}
>
<div className="py-20 pt-32 p-4 flex flex-col items-center max-w-3xl bg-[radial-gradient(circle,rgba(255,255,255,0.7)_0%,rgba(255,255,255,0.7)_50%,rgba(255,255,255,0)_100%)]">
<Heading1 className="mb-4">
A open-source
<br />
alternative to Mixpanel
</Heading1>
<p className="mb-8">
Combine Mixpanel and Plausible and you get Openpanel. A simple
analytics tool that respects privacy.
</p>
<JoinWaitlist />
<div className="flex flex-wrap gap-10 mt-8 max-w-xl justify-center">
{features.map(({ icon: Icon, title }) => (
<div className="flex gap-2 items-center justify-center">
<Icon className="text-blue-light " />
{title}
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -10,8 +10,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { cn } from '@/utils/cn';
export function JoinWaitlist() { interface JoinWaitlistProps {
className?: string;
}
export function JoinWaitlist({ className }: JoinWaitlistProps) {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -59,7 +64,10 @@ export function JoinWaitlist() {
<div className="relative w-full mb-8"> <div className="relative w-full mb-8">
<input <input
placeholder="Enter your email" placeholder="Enter your email"
className="border border-slate-100 rounded-md shadow-sm bg-white h-12 w-full px-4 outline-none focus:ring-1 ring-black" className={cn(
'border border-slate-100 rounded-md shadow-sm bg-white h-12 w-full px-4 outline-none focus:ring-1 ring-black text-blue-darker',
className
)}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
/> />

View File

@@ -3,6 +3,7 @@ import { cn } from '@/utils/cn';
import '@/styles/globals.css'; import '@/styles/globals.css';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Bricolage_Grotesque } from 'next/font/google';
import Script from 'next/script'; import Script from 'next/script';
import { defaultMeta } from './meta'; import { defaultMeta } from './meta';
@@ -14,6 +15,12 @@ export const metadata: Metadata = {
}, },
}; };
const font = Bricolage_Grotesque({
display: 'swap',
subsets: ['latin'],
weights: [400, 700],
});
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
@@ -21,7 +28,12 @@ export default async function RootLayout({
}) { }) {
return ( return (
<html lang="en" className="light"> <html lang="en" className="light">
<body className={cn('min-h-screen font-sans antialiased grainy')}> <body
className={cn(
'min-h-screen antialiased grainy text-slate-600',
font.className
)}
>
{children} {children}
</body> </body>
<Script <Script

View File

@@ -1,121 +1,58 @@
import { Logo } from '@/components/Logo'; import { Logo } from '@/components/Logo';
import { Button } from '@/components/ui/button'; import Image from 'next/image';
import type { LucideIcon } from 'lucide-react';
import {
BarChart2,
BellIcon,
ClockIcon,
CloudIcon,
CompassIcon,
ConeIcon,
CookieIcon,
DollarSignIcon,
Globe2Icon,
KeyIcon,
LayoutPanelTopIcon,
LockIcon,
UserRoundSearchIcon,
UsersIcon,
} from 'lucide-react';
import { HomeCarousel } from './carousel'; import { PreviewCarousel } from './carousel';
import { Heading1, Heading2, Lead, Lead2, Paragraph } from './copy'; import { Heading2, Lead2, Paragraph } from './copy';
import { Hero } from './hero';
import { JoinWaitlist } from './join-waitlist'; import { JoinWaitlist } from './join-waitlist';
import { Section, Sections } from './section'; import { Sections } from './section';
const features = [
{
title: 'Great overview',
icon: LayoutPanelTopIcon,
},
{
title: 'Beautiful charts',
icon: BarChart2,
},
{
title: 'Privacy focused',
icon: LockIcon,
},
{
title: 'Open-source',
icon: Globe2Icon,
},
{
title: 'No cookies',
icon: CookieIcon,
},
{
title: 'User journey',
icon: UsersIcon,
},
];
export default function Page() { export default function Page() {
return ( return (
<div> <div>
<div className="max-w-6xl p-4 mx-auto absolute top-0 left-0 right-0 py-6"> <Hero />
<div className="flex justify-between"> <div className="bg-gradient-to-b from-blue-light to-[#FFFFFF] py-16 md:py-40 text-center">
<Logo /> <PreviewCarousel />
</div> </div>
</div> <div className="container">
<div className="mb-24">
<div className="flex flex-col items-center bg-gradient-to-br from-white via-white to-blue-200 w-full text-center text-blue-950"> <Heading2 className="md:text-5xl mb-2 leading-none">
<div className="py-20 pt-32 p-4 flex flex-col items-center max-w-3xl "> Analytics should be easy
<Heading1 className="mb-4 fancy-text">
A open-source
<br /> <br />
alternative to Mixpanel and powerful
</Heading1> </Heading2>
<Lead className="mb-8"> <Lead2>
Combine Mixpanel and Plausible and you get Openpanel. A simple The power of Mixpanel, the ease of Plausible and nothing from Google
analytics tool that respects privacy. Analytics 😉
</Lead> </Lead2>
<JoinWaitlist />
<div className="grid grid-cols-2 md:grid-cols-3 gap-8 mt-8">
{features.map(({ icon: Icon, title }) => (
<div className="flex gap-2 items-center justify-center">
<Icon />
{title}
</div>
))}
</div>
</div>
</div> </div>
<Sections /> <Sections />
<div className="bg-blue-800 p-4 py-8 md:py-16 text-center">
<Heading2 className="text-slate-100 mb-4">
Get a feel how it looks
</Heading2>
<Lead className="text-slate-200 mb-16">
We've crafted a clean and intuitive interface because analytics should
<br />
be straightforward, unlike the complexity often associated with Google
Analytics. 😅
</Lead>
<HomeCarousel />
</div> </div>
<div className="p-4 py-8 md:py-16 text-center flex flex-col items-center"> <div className="container mt-40">
<Heading2 className="mb-4">Another analytic tool?</Heading2> <div className="flex flex-col md:flex-row gap-8">
<div className="mb-4 md:w-1/2 flex-shrink-0 relative">
<Heading2>Another analytic tool? Really?</Heading2>
{/* <SirenIcon
strokeWidth={0.5}
size={300}
className="opacity-10 absolute -rotate-12 -left-20 -top-10"
/> */}
</div>
<div className="flex flex-col gap-4 max-w-3xl"> <div className="flex flex-col gap-4 max-w-3xl">
<h3 className="text-lg font-bold text-blue-dark">TL;DR</h3>
<Paragraph> <Paragraph>
<strong>TL;DR</strong> Our open-source analytic library fills a Our open-source analytic library fills a crucial gap by combining
crucial gap by combining the strengths of Mixpanel's powerful the strengths of Mixpanel's powerful features with Plausible's
features with Plausible's clear overview page. Motivated by the lack clear overview page. Motivated by the lack of an open-source
of an open-source alternative to Mixpanel and inspired by alternative to Mixpanel and inspired by Plausible's simplicity, we
Plausible's simplicity, we aim to create an intuitive platform with aim to create an intuitive platform with predictable pricing. With
predictable pricing. With a single-tier pricing model and limits a single-tier pricing model and limits only on monthly event
only on monthly event counts, our goal is to democratize analytics, counts, our goal is to democratize analytics, offering
offering unrestricted access to all features while ensuring unrestricted access to all features while ensuring affordability
affordability and transparency for users of all project sizes. and transparency for users of all project sizes.
</Paragraph> </Paragraph>
<div className="flex gap-2 w-full justify-center my-8"> <h3 className="text-lg font-bold text-blue-dark mt-12">The why</h3>
<div className="rounded-full h-2 w-10 bg-blue-200"></div>
<div className="rounded-full h-2 w-10 bg-blue-400"></div>
<div className="rounded-full h-2 w-10 bg-blue-600"></div>
<div className="rounded-full h-2 w-10 bg-blue-800"></div>
</div>
<Paragraph> <Paragraph>
Our open-source analytic library emerged from a clear need within Our open-source analytic library emerged from a clear need within
the analytics community. While platforms like Mixpanel offer the analytics community. While platforms like Mixpanel offer
@@ -127,10 +64,11 @@ export default function Page() {
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
One significant motivation behind our endeavor was the absence of an One significant motivation behind our endeavor was the absence of
open-source alternative to Mixpanel. We believe in the importance of an open-source alternative to Mixpanel. We believe in the
accessibility and transparency in analytics, which led us to embark importance of accessibility and transparency in analytics, which
on creating a solution that anyone can freely use and contribute to. led us to embark on creating a solution that anyone can freely use
and contribute to.
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
@@ -138,8 +76,8 @@ export default function Page() {
clarity, we aim to build upon their foundation and further refine clarity, we aim to build upon their foundation and further refine
the user experience. By harnessing the best practices demonstrated the user experience. By harnessing the best practices demonstrated
by Plausible, we aspire to create an intuitive and streamlined by Plausible, we aspire to create an intuitive and streamlined
analytics platform that empowers users to derive actionable insights analytics platform that empowers users to derive actionable
effortlessly. insights effortlessly.
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
@@ -147,23 +85,70 @@ export default function Page() {
Mixpanel underscored another critical aspect driving our project: Mixpanel underscored another critical aspect driving our project:
the need for predictable pricing. As project owners ourselves, we the need for predictable pricing. As project owners ourselves, we
encountered the frustration of escalating costs as our user base encountered the frustration of escalating costs as our user base
grew. Therefore, we are committed to offering a single-tier pricing grew. Therefore, we are committed to offering a single-tier
model that provides unlimited access to all features without the pricing model that provides unlimited access to all features
fear of unexpected expenses. without the fear of unexpected expenses.
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
In line with our commitment to fairness and accessibility, our In line with our commitment to fairness and accessibility, our
pricing model will only impose limits on the number of events users pricing model will only impose limits on the number of events
can send each month. This approach, akin to Plausible's, ensures users can send each month. This approach, akin to Plausible's,
that users have the freedom to explore and utilize our platform to ensures that users have the freedom to explore and utilize our
its fullest potential without arbitrary restrictions on reports or platform to its fullest potential without arbitrary restrictions
user counts. Ultimately, our goal is to democratize analytics by on reports or user counts. Ultimately, our goal is to democratize
offering a reliable, transparent, and cost-effective solution for analytics by offering a reliable, transparent, and cost-effective
projects of all sizes. solution for projects of all sizes.
</Paragraph> </Paragraph>
</div> </div>
</div> </div>
</div> </div>
<footer className="bg-blue-darker text-white relative mt-40 relative">
<div className="inset-0 absolute h-full w-full bg-[radial-gradient(circle,rgba(255,255,255,0.2)_0%,rgba(255,255,255,0)_100%)]"></div>
<div className="relative container flex flex-col items-center text-center">
<div className="my-24">
<Heading2 className="text-white mb-2">Get early access</Heading2>
<Lead2>
Ready to set your analytics free? Get on our waitlist.
</Lead2>
<div className="mt-8">
<JoinWaitlist className="text-white bg-white/20 border-white/30 focus:ring-white" />
</div>
</div>
<div className="overflow-hidden rounded-xl">
<div className="p-3 bg-white/20">
<Image
src="/demo/overview-min.png"
width={1080}
height={608}
alt="Openpanel overview page"
className="w-full rounded-lg"
/>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<div className="h-px w-full bg-[radial-gradient(circle,rgba(255,255,255,0.7)_0%,rgba(255,255,255,0.7)_50%,rgba(255,255,255,0)_100%)]"></div>
<div className="p-4 bg-blue-darker">
<div className="container">
<div className="flex justify-between items-center text-sm">
<Logo />
<a
className="hover:underline"
href="https://twitter.com/CarlLindesvard"
target="_blank"
rel="nofollow"
>
Follow on X
</a>
</div>
</div>
</div>
</div>
</footer>
</div>
); );
} }

View File

@@ -3,14 +3,41 @@
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { LucideIcon, LucideProps } from 'lucide-react'; import type { LucideIcon, LucideProps } from 'lucide-react';
import { import {
ArrowUpFromDotIcon,
BarChart2Icon,
BellIcon, BellIcon,
BookmarkIcon,
CheckCircle,
ClockIcon, ClockIcon,
CloudIcon, CloudIcon,
CloudLightning,
CloudLightningIcon,
CompassIcon, CompassIcon,
ConeIcon, ConeIcon,
DatabaseIcon,
DollarSignIcon, DollarSignIcon,
DownloadIcon,
FileIcon,
FilterIcon,
FolderIcon,
FolderOpenIcon,
HandCoinsIcon,
HandshakeIcon,
KeyIcon, KeyIcon,
PieChartIcon,
PointerIcon,
RouteIcon,
ServerIcon,
ShieldPlusIcon,
ShoppingCartIcon,
SquareUserRound,
StarIcon,
ThumbsUp,
ThumbsUpIcon,
TrendingUpIcon,
UserRoundSearchIcon, UserRoundSearchIcon,
UsersIcon,
WebhookIcon,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -25,6 +52,7 @@ import {
Blob9, Blob9,
} from './blob'; } from './blob';
import { Heading2, Lead2 } from './copy'; import { Heading2, Lead2 } from './copy';
import { Widget } from './widget';
interface SectionItem { interface SectionItem {
title: string; title: string;
@@ -32,7 +60,8 @@ interface SectionItem {
icon: LucideIcon; icon: LucideIcon;
color: string; color: string;
soon?: string; soon?: string;
blob: React.ComponentType<LucideProps>; icons: LucideIcon[];
className: string;
} }
const sections: SectionItem[] = [ const sections: SectionItem[] = [
@@ -42,7 +71,8 @@ const sections: SectionItem[] = [
'Take control of your data privacy and ownership with our platform, ensuring full transparency and security.', 'Take control of your data privacy and ownership with our platform, ensuring full transparency and security.',
icon: KeyIcon, icon: KeyIcon,
color: '#2563EB', color: '#2563EB',
blob: Blob1, icons: [FolderIcon, DatabaseIcon, ShieldPlusIcon, KeyIcon],
className: 'bg-blue-light',
}, },
{ {
title: 'Cloud or Self-Hosting', title: 'Cloud or Self-Hosting',
@@ -50,7 +80,8 @@ const sections: SectionItem[] = [
'Choose between the flexibility of cloud-based hosting or the autonomy of self-hosting to tailor your analytics infrastructure to your needs.', 'Choose between the flexibility of cloud-based hosting or the autonomy of self-hosting to tailor your analytics infrastructure to your needs.',
icon: CloudIcon, icon: CloudIcon,
color: '#ff7557', color: '#ff7557',
blob: Blob2, icons: [CloudIcon, CheckCircle, ServerIcon, DownloadIcon],
className: '', // 'bg-[#ff7557]',
}, },
{ {
title: 'Real-Time Events', title: 'Real-Time Events',
@@ -58,15 +89,17 @@ const sections: SectionItem[] = [
'Stay up-to-date with real-time event tracking, enabling instant insights into user actions as they happen.', 'Stay up-to-date with real-time event tracking, enabling instant insights into user actions as they happen.',
icon: ClockIcon, icon: ClockIcon,
color: '#7fe1d8', color: '#7fe1d8',
blob: Blob3, icons: [CloudLightningIcon, ShoppingCartIcon, ArrowUpFromDotIcon],
className: '', // bg-[#7fe1d8]
}, },
{ {
title: 'Deep Dive into User Behavior', title: 'Deep Dive into User Behaviors',
description: description:
"Gain profound insights into user behavior with comprehensive analytics tools, allowing you to understand your audience's actions and preferences.", "Gain profound insights into user behavior with comprehensive analytics tools, allowing you to understand your audience's actions and preferences.",
icon: UserRoundSearchIcon, icon: UserRoundSearchIcon,
color: '#f8bc3c', color: '#f8bc3c',
blob: Blob4, icons: [UsersIcon, RouteIcon, BookmarkIcon],
className: 'bg-blue-dark', //'bg-[#f8bc3c]',
}, },
{ {
title: 'Powerful Report Explorer', title: 'Powerful Report Explorer',
@@ -74,7 +107,8 @@ const sections: SectionItem[] = [
'Explore and analyze your data effortlessly with our powerful report explorer, simplifying the process of deriving meaningful insights.', 'Explore and analyze your data effortlessly with our powerful report explorer, simplifying the process of deriving meaningful insights.',
icon: CompassIcon, icon: CompassIcon,
color: '#b3596e', color: '#b3596e',
blob: Blob5, icons: [ThumbsUpIcon, TrendingUpIcon, PieChartIcon, BarChart2Icon],
className: 'bg-[#ff7557]',
}, },
{ {
soon: 'Coming soon', soon: 'Coming soon',
@@ -83,7 +117,8 @@ const sections: SectionItem[] = [
'Track user conversion funnels seamlessly, providing valuable insights into user journey optimization.', 'Track user conversion funnels seamlessly, providing valuable insights into user journey optimization.',
icon: ConeIcon, icon: ConeIcon,
color: '#72bef4', color: '#72bef4',
blob: Blob6, icons: [ConeIcon, FilterIcon],
className: '', //'bg-[#72bef4]',
}, },
{ {
soon: 'Coming with our native app', soon: 'Coming with our native app',
@@ -92,7 +127,8 @@ const sections: SectionItem[] = [
'Stay informed about conversions, events, and peaks with our upcoming push notification tool, empowering you to monitor and respond to critical activities in real-time.', 'Stay informed about conversions, events, and peaks with our upcoming push notification tool, empowering you to monitor and respond to critical activities in real-time.',
icon: BellIcon, icon: BellIcon,
color: '#ffb27a', color: '#ffb27a',
blob: Blob7, icons: [WebhookIcon, BellIcon],
className: '', //'bg-[#ffb27a]',
}, },
{ {
title: 'Cost-Effective Alternative to Mixpanel', title: 'Cost-Effective Alternative to Mixpanel',
@@ -100,7 +136,8 @@ const sections: SectionItem[] = [
'Enjoy the same powerful analytics capabilities as Mixpanel at a fraction of the cost, ensuring affordability without compromising on quality.', 'Enjoy the same powerful analytics capabilities as Mixpanel at a fraction of the cost, ensuring affordability without compromising on quality.',
icon: DollarSignIcon, icon: DollarSignIcon,
color: '#0f7ea0', color: '#0f7ea0',
blob: Blob8, icons: [DollarSignIcon, HandCoinsIcon, HandshakeIcon, StarIcon],
className: 'bg-[#3ba974]',
}, },
{ {
soon: 'Something Plausible lacks', soon: 'Something Plausible lacks',
@@ -113,61 +150,59 @@ const sections: SectionItem[] = [
); );
}) as unknown as LucideIcon, }) as unknown as LucideIcon,
color: '#3ba974', color: '#3ba974',
blob: Blob9, icons: [FolderIcon, DatabaseIcon, ShieldPlusIcon, KeyIcon],
className: 'bg-[#f8bc3c]',
}, },
]; ];
interface SectionProps extends SectionItem { // To lazy to think now...
reverse?: boolean; function checkIndex(index: number) {
switch (index) {
case 0:
case 3:
case 4:
case 7:
case 8:
case 10:
return true;
default:
return false;
} }
export function Section({
title,
description,
icon: Icon,
blob: Blob,
color,
soon,
reverse,
}: SectionProps) {
return (
<div key={title} className={'border-b border-border'}>
<div className="w-full max-w-6xl mx-auto px-4">
<div
className={cn(
'flex py-16 flex-col justify-center',
reverse ? 'md:flex-row' : 'md:flex-row-reverse'
)}
>
<div className="md:w-1/2 flex-shrink-0 justify-center items-center flex max-md:mb-8 overflow-hidden rounded-lg">
<div className="bg-slate-50 rounded-3xl">
<Blob
style={{ fill: color }}
className="w-[600px] opacity-20 transition-transform animate-[spin_60s_ease-in-out_infinite] -m-[100px]"
/>
</div>
<Icon className="w-40 h-40 absolute" strokeWidth={2} />
</div>
<div className="justify-center flex-col flex">
{!!soon && (
<div className="rounded-full border border-border p-2 px-4 leading-none mb-4 self-start">
{soon}
</div>
)}
<Heading2 className="mb-4">{title}</Heading2>
<Lead2>{description}</Lead2>
</div>
</div>
</div>
</div>
);
} }
export function Sections() { export function Sections() {
return ( return (
<> <>
{sections.map((section, index) => ( <div className="grid grid-cols-1 md:grid-cols-3 gap-8 gap-y-16">
<Section key={index} {...section} reverse={index % 2 === 1} /> {sections.map((section, i) => {
))} const even = checkIndex(i);
const offsets = even
? [
'-top-10 -left-10 rotate-12',
'top-10 -rotate-12',
'-right-5',
'-right-10 -top-20',
]
: ['-top-10 -left-20 rotate-12', 'top-10 -rotate-12', '-right-5'];
const className = even
? cn('text-white [&_h3]:text-white col-span-2', section.className)
: cn('border border-border', section.className);
return (
<Widget
key={section.title}
title={section.title}
className={className}
icons={section.icons}
offsets={offsets}
>
<p>{section.description}</p>
</Widget>
);
})}
</div>
</> </>
); );
} }

View File

@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { cn } from '@/utils/cn';
import type { LucideIcon } from 'lucide-react';
import { Heading3 } from './copy';
interface WidgetProps {
title: string;
children: ReactNode;
className?: string;
icons: LucideIcon[];
offsets: string[];
}
export function Widget({
title,
children,
className,
icons,
offsets,
}: WidgetProps) {
return (
<div
className={cn(
'p-10 rounded-xl relative overflow-hidden flex flex-col hover:scale-105 transition-all duration-300 ease-in-out bg-white hover:shadow min-h-[300px] max-md:col-span-3',
className
)}
>
<Heading3 className="mb-4">{title}</Heading3>
{children}
<div className="flex justify-between mt-auto">
{icons.map((Icon, i) => (
<Icon
key={i}
size={120}
className={cn('flex-shrink-0 opacity-10 relative', offsets?.[i])}
strokeWidth={1.5}
/>
))}
</div>
</div>
);
}

View File

@@ -1,43 +1,41 @@
import * as React from "react" import * as React from 'react';
import useEmblaCarousel, { import { Button } from '@/components/ui/button';
type UseEmblaCarouselType, import { cn } from '@/utils/cn';
} from "embla-carousel-react" import useEmblaCarousel from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from "lucide-react" import type { UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from "@/utils/cn" type CarouselApi = UseEmblaCarouselType[1];
import { Button } from "@/components/ui/button" type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselApi = UseEmblaCarouselType[1] interface CarouselProps {
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> opts?: CarouselOptions;
type CarouselOptions = UseCarouselParameters[0] plugins?: CarouselPlugin;
type CarouselPlugin = UseCarouselParameters[1] orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
} }
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error('useCarousel must be used within a <Carousel />');
} }
return context return context;
} }
const Carousel = React.forwardRef< const Carousel = React.forwardRef<
@@ -46,7 +44,7 @@ const Carousel = React.forwardRef<
>( >(
( (
{ {
orientation = "horizontal", orientation = 'horizontal',
opts, opts,
setApi, setApi,
plugins, plugins,
@@ -59,64 +57,64 @@ const Carousel = React.forwardRef<
const [carouselRef, api] = useEmblaCarousel( const [carouselRef, api] = useEmblaCarousel(
{ {
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === 'horizontal' ? 'x' : 'y',
}, },
plugins plugins
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) { if (!api) {
return return;
} }
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === 'ArrowLeft') {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === 'ArrowRight') {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext]
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) { if (!api || !setApi) {
return return;
} }
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) { if (!api) {
return return;
} }
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on('reInit', onSelect);
api.on("select", onSelect) api.on('select', onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off('select', onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@@ -125,7 +123,7 @@ const Carousel = React.forwardRef<
api: api, api: api,
opts, opts,
orientation: orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev, scrollPrev,
scrollNext, scrollNext,
canScrollPrev, canScrollPrev,
@@ -135,7 +133,7 @@ const Carousel = React.forwardRef<
<div <div
ref={ref} ref={ref}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
className={cn("relative", className)} className={cn('relative', className)}
role="region" role="region"
aria-roledescription="carousel" aria-roledescription="carousel"
{...props} {...props}
@@ -143,38 +141,38 @@ const Carousel = React.forwardRef<
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
) );
Carousel.displayName = "Carousel" Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef< const CarouselContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div ref={carouselRef} className="overflow-hidden"> <div ref={carouselRef} className="overflow-hidden">
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex", 'flex',
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className className
)} )}
{...props} {...props}
/> />
</div> </div>
) );
}) });
CarouselContent.displayName = "CarouselContent" CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef< const CarouselItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@@ -182,21 +180,21 @@ const CarouselItem = React.forwardRef<
role="group" role="group"
aria-roledescription="slide" aria-roledescription="slide"
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", 'min-w-0 shrink-0 grow-0 basis-full',
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
CarouselItem.displayName = "CarouselItem" CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef< const CarouselPrevious = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@@ -204,10 +202,10 @@ const CarouselPrevious = React.forwardRef<
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
"absolute h-8 w-8 rounded-full", 'absolute h-8 w-8 rounded-full',
orientation === "horizontal" orientation === 'horizontal'
? "-left-12 top-1/2 -translate-y-1/2" ? '-left-12 top-1/2 -translate-y-1/2'
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className className
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
@@ -217,15 +215,15 @@ const CarouselPrevious = React.forwardRef<
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
}) });
CarouselPrevious.displayName = "CarouselPrevious" CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef< const CarouselNext = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@@ -233,10 +231,10 @@ const CarouselNext = React.forwardRef<
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
"absolute h-8 w-8 rounded-full", 'absolute h-8 w-8 rounded-full',
orientation === "horizontal" orientation === 'horizontal'
? "-right-12 top-1/2 -translate-y-1/2" ? '-right-12 top-1/2 -translate-y-1/2'
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className className
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
@@ -246,9 +244,9 @@ const CarouselNext = React.forwardRef<
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
}) });
CarouselNext.displayName = "CarouselNext" CarouselNext.displayName = 'CarouselNext';
export { export {
type CarouselApi, type CarouselApi,
@@ -257,4 +255,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@@ -73,7 +73,7 @@
} }
.fancy-text { .fancy-text {
@apply text-transparent inline-block bg-gradient-to-br from-blue-600 to-purple-900 bg-clip-text; @apply text-transparent inline-block bg-gradient-to-br from-blue-200 to-blue-400 bg-clip-text;
} }
strong { strong {

View File

@@ -1,22 +1,5 @@
const colors = [
'#2563EB',
'#ff7557',
'#7fe1d8',
'#f8bc3c',
'#b3596e',
'#72bef4',
'#ffb27a',
'#0f7ea0',
'#3ba974',
'#febbb2',
'#cb80dc',
'#5cb7af',
'#7856ff',
];
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
const config = { const config = {
safelist: [...colors.map((color) => `chart-${color}`)],
content: [ content: [
'./pages/**/*.{ts,tsx}', './pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}', './components/**/*.{ts,tsx}',
@@ -24,8 +7,16 @@ const config = {
'./src/**/*.{ts,tsx}', './src/**/*.{ts,tsx}',
], ],
theme: { theme: {
container: {
center: true,
padding: '1rem',
},
extend: { extend: {
colors: { colors: {
['blue-light']: '#2C97FE',
['blue-dark']: '#111F46',
['blue-darker']: '#051030',
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
@@ -59,12 +50,6 @@ const config = {
DEFAULT: 'hsl(var(--card))', DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))', foreground: 'hsl(var(--card-foreground))',
}, },
...colors.reduce((acc, color, index) => {
return {
...acc,
[`chart-${index}`]: color,
};
}, {}),
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',