feat: new public website
This commit is contained in:
43
apps/public/src/components/article-card.tsx
Normal file
43
apps/public/src/components/article-card.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ArticleCard({
|
||||
url,
|
||||
title,
|
||||
tag,
|
||||
cover,
|
||||
team,
|
||||
date,
|
||||
}: {
|
||||
url: string;
|
||||
title: string;
|
||||
tag?: string;
|
||||
cover: string;
|
||||
team?: string;
|
||||
date: Date;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
key={url}
|
||||
className="border rounded-lg overflow-hidden bg-background-light col hover:scale-105 transition-all duration-300 hover:shadow-lg hover:shadow-background-dark"
|
||||
>
|
||||
<Image
|
||||
src={cover}
|
||||
alt={title}
|
||||
width={323}
|
||||
height={181}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="p-4 col flex-1">
|
||||
{tag && <span className="font-mono text-xs mb-2">{tag}</span>}
|
||||
<span className="flex-1 mb-6">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
28
apps/public/src/components/battery-icon.tsx
Normal file
28
apps/public/src/components/battery-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import {
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
type LucideProps,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function BatteryIcon(props: LucideProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const icons = [BatteryLowIcon, BatteryMediumIcon, BatteryFullIcon];
|
||||
|
||||
const Icon = icons[index];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((index + 1) % icons.length);
|
||||
}, 750);
|
||||
return () => clearInterval(interval);
|
||||
}, [index]);
|
||||
|
||||
if (!Icon) {
|
||||
return <div className={props.className} />;
|
||||
}
|
||||
|
||||
return <Icon {...props} />;
|
||||
}
|
||||
7
apps/public/src/components/common-sdk-config.mdx
Normal file
7
apps/public/src/components/common-sdk-config.mdx
Normal file
@@ -0,0 +1,7 @@
|
||||
##### 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
|
||||
80
apps/public/src/components/competition.tsx
Normal file
80
apps/public/src/components/competition.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const brandConfig = {
|
||||
Mixpanel: '#7A59FF',
|
||||
'Google Analytics': '#E37400',
|
||||
Amplitude: '#00CF98',
|
||||
} as const;
|
||||
|
||||
const words = Object.keys(brandConfig);
|
||||
|
||||
function useWordCycle(words: string[], interval: number, mounted: boolean) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [isInitial, setIsInitial] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
setIndex(Math.floor(Math.random() * words.length));
|
||||
setIsInitial(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setIndex((current) => (current + 1) % words.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [words, interval, isInitial, mounted]);
|
||||
|
||||
return words[index];
|
||||
}
|
||||
|
||||
export function Competition() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const word = useWordCycle(words, 2100, mounted);
|
||||
const color = brandConfig[word as keyof typeof brandConfig];
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<span className="block truncate leading-tight -mt-1" style={{ color }}>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={word}
|
||||
className="block truncate leading-tight -mt-1"
|
||||
style={{ color }}
|
||||
>
|
||||
{word?.split('').map((char, index) => (
|
||||
<motion.span
|
||||
key={`${word}-${char}-${index.toString()}`}
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -10, opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
delay: index * 0.015,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
style={{ display: 'inline-block', whiteSpace: 'pre' }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
12
apps/public/src/components/device-id-warning.tsx
Normal file
12
apps/public/src/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>
|
||||
);
|
||||
}
|
||||
47
apps/public/src/components/faq.tsx
Normal file
47
apps/public/src/components/faq.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Script from 'next/script';
|
||||
import Markdown from 'react-markdown';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from './ui/accordion';
|
||||
|
||||
export const Faqs = ({ children }: { children: React.ReactNode }) => (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center border rounded-3xl [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
|
||||
>
|
||||
{children}
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
export const FaqItem = ({
|
||||
question,
|
||||
children,
|
||||
}: { question: string; children: string | React.ReactNode }) => (
|
||||
<AccordionItem
|
||||
value={question}
|
||||
itemScope
|
||||
itemProp="mainEntity"
|
||||
itemType="https://schema.org/Question"
|
||||
className="[&_[role=region]]:px-4"
|
||||
>
|
||||
<AccordionTrigger className="text-left" itemProp="name">
|
||||
{question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
itemProp="acceptedAnswer"
|
||||
itemScope
|
||||
itemType="https://schema.org/Answer"
|
||||
className="prose"
|
||||
>
|
||||
{typeof children === 'string' ? (
|
||||
<Markdown>{children}</Markdown>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
76
apps/public/src/components/feature-card.tsx
Normal file
76
apps/public/src/components/feature-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FeatureCardProps {
|
||||
illustration?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'large';
|
||||
}
|
||||
|
||||
interface FeatureCardContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FeatureCardBackground = () => (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 bg-linear-to-br opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100',
|
||||
'dark:from-blue-500/10 dark:via-transparent dark:to-emerald-500/5',
|
||||
'light:from-blue-800/20 light:via-transparent light:to-emerald-900/10',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export function FeatureCardContainer({
|
||||
children,
|
||||
className,
|
||||
}: FeatureCardContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col gap-8 p-6 rounded-3xl border bg-background group relative overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureCard({
|
||||
illustration,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
}: FeatureCardProps) {
|
||||
if (illustration) {
|
||||
return (
|
||||
<FeatureCardContainer className={className}>
|
||||
{illustration}
|
||||
<div className="col gap-2" data-content>
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureCardContainer className={className}>
|
||||
{Icon && <Icon className="size-6" />}
|
||||
<div className="col gap-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
24
apps/public/src/components/figure.tsx
Normal file
24
apps/public/src/components/figure.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function Figure({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
className,
|
||||
}: { src: string; alt: string; caption: string; className?: string }) {
|
||||
return (
|
||||
<figure className={cn('-mx-4', className)}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt || caption}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<figcaption className="text-center text-sm text-muted-foreground mt-2">
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
79
apps/public/src/components/flow-step.tsx
Normal file
79
apps/public/src/components/flow-step.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FlowStepProps {
|
||||
step: number;
|
||||
actor: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
visitor: User,
|
||||
website: Globe,
|
||||
backend: Server,
|
||||
payment: CreditCard,
|
||||
success: CheckCircle,
|
||||
};
|
||||
|
||||
const iconColorMap = {
|
||||
visitor: 'text-blue-500',
|
||||
website: 'text-green-500',
|
||||
backend: 'text-purple-500',
|
||||
payment: 'text-yellow-500',
|
||||
success: 'text-green-600',
|
||||
};
|
||||
|
||||
const iconBorderColorMap = {
|
||||
visitor: 'border-blue-500',
|
||||
website: 'border-green-500',
|
||||
backend: 'border-purple-500',
|
||||
payment: 'border-yellow-500',
|
||||
success: 'border-green-600',
|
||||
};
|
||||
|
||||
export function FlowStep({
|
||||
step,
|
||||
actor,
|
||||
description,
|
||||
children,
|
||||
icon = 'visitor',
|
||||
isLast = false,
|
||||
}: FlowStepProps) {
|
||||
const Icon = iconMap[icon];
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 mb-4 min-w-0">
|
||||
{/* Step number and icon */}
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="relative z-10 bg-background">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
|
||||
{step}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
|
||||
>
|
||||
<Icon
|
||||
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector line - extends from badge through content to next step */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pt-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
{children && <div className="mt-3 min-w-0">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
apps/public/src/components/footer.tsx
Normal file
175
apps/public/src/components/footer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { articleSource, compareSource } from '@/lib/source';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Logo } from './logo';
|
||||
export async function Footer() {
|
||||
const articles = (await articleSource.getPages()).sort(
|
||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
||||
);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<footer className="text-sm relative overflow-hidden pt-32">
|
||||
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
|
||||
<div className="absolute inset-0 bg-linear-to-b from-background to-transparent" />
|
||||
<Logo className="w-[900px] shrink-0" />
|
||||
</div>
|
||||
<div className="container grid grid-cols-1 md:grid-cols-4 gap-12 md:gap-8 relative">
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Useful links</h3>
|
||||
<Links
|
||||
data={[
|
||||
{ title: 'About', url: '/about' },
|
||||
{ title: 'Contact', url: '/contact' },
|
||||
{ title: 'Become a supporter', url: '/supporter' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Resources</h3>
|
||||
<Links
|
||||
data={[
|
||||
{ title: 'Pricing', url: '/pricing' },
|
||||
{ title: 'Documentation', url: '/docs' },
|
||||
{ title: 'SDKs', url: '/docs/sdks' },
|
||||
{ title: 'Articles', url: '/articles' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Compare</h3>
|
||||
<Links
|
||||
data={compareSource.map((item) => ({
|
||||
url: item.url,
|
||||
title: item?.hero?.heading,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Latest articles</h3>
|
||||
<Links
|
||||
data={articles.slice(0, 10).map((article) => ({
|
||||
title: article.data.title,
|
||||
url: article.url,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||
<div className="container col md:row justify-between gap-8">
|
||||
<div>
|
||||
<Link href="/" className="row items-center font-medium -ml-3">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions().nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
<Social />
|
||||
</div>
|
||||
<div className="container flex flex-col-reverse md:row justify-between gap-8">
|
||||
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
|
||||
<div className="col lg:row gap-2 md:gap-4">
|
||||
<Link href="/sitemap.xml">Sitemap</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/terms">Terms of Service</Link>
|
||||
<Link href="/cookies">Cookie Policy (just kidding)</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Links({ data }: { data: { title: string; url: string }[] }) {
|
||||
return (
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
{data.map((item) => (
|
||||
<li key={item.url} className="truncate">
|
||||
<Link
|
||||
href={item.url}
|
||||
className="hover:text-foreground transition-colors"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Social() {
|
||||
return (
|
||||
<div className="md: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"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<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"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<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"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<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"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<MailIcon className="size-6" />
|
||||
</Link>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://status.openpanel.dev"
|
||||
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start max-md:ml-auto"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<span>Operational</span>
|
||||
<div className="size-2 bg-emerald-500 rounded-full" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
apps/public/src/components/get-started-button.tsx
Normal file
23
apps/public/src/components/get-started-button.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function GetStartedButton({
|
||||
text,
|
||||
href = 'https://dashboard.openpanel.dev/onboarding',
|
||||
className,
|
||||
}: {
|
||||
text?: React.ReactNode;
|
||||
className?: string;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button size="lg" asChild className={cn('group', className)}>
|
||||
<Link href={href}>
|
||||
{text ?? 'Get started now'}
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
42
apps/public/src/components/github-button.tsx
Normal file
42
apps/public/src/components/github-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getGithubRepoInfo } from '@/lib/github';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, 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(4_800);
|
||||
useEffect(() => {
|
||||
getGithubRepoInfo().then((res) => {
|
||||
if (res?.stargazers_count) {
|
||||
setStars(res.stargazers_count);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<Button variant={'secondary'} asChild>
|
||||
<Link href="https://git.new/openpanel" className="hidden md:flex">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
99
apps/public/src/components/infinite-moving-cards.tsx
Normal file
99
apps/public/src/components/infinite-moving-cards.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// Thank you: https://ui.aceternity.com/components/infinite-moving-cards
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export const InfiniteMovingCards = <T,>({
|
||||
items,
|
||||
direction = 'left',
|
||||
speed = 'fast',
|
||||
pauseOnHover = true,
|
||||
className,
|
||||
renderItem,
|
||||
}: {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
direction?: 'left' | 'right';
|
||||
speed?: 'fast' | 'normal' | 'slow';
|
||||
pauseOnHover?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollerRef = React.useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
addAnimation();
|
||||
}, []);
|
||||
|
||||
const [start, setStart] = useState(false);
|
||||
function addAnimation() {
|
||||
if (containerRef.current && scrollerRef.current) {
|
||||
const scrollerContent = Array.from(scrollerRef.current.children);
|
||||
|
||||
scrollerContent.forEach((item) => {
|
||||
const duplicatedItem = item.cloneNode(true);
|
||||
if (scrollerRef.current) {
|
||||
scrollerRef.current.appendChild(duplicatedItem);
|
||||
}
|
||||
});
|
||||
|
||||
getDirection();
|
||||
getSpeed();
|
||||
setStart(true);
|
||||
}
|
||||
}
|
||||
const getDirection = () => {
|
||||
if (containerRef.current) {
|
||||
if (direction === 'left') {
|
||||
containerRef.current.style.setProperty(
|
||||
'--animation-direction',
|
||||
'forwards',
|
||||
);
|
||||
} else {
|
||||
containerRef.current.style.setProperty(
|
||||
'--animation-direction',
|
||||
'reverse',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getSpeed = () => {
|
||||
if (containerRef.current) {
|
||||
if (speed === 'fast') {
|
||||
containerRef.current.style.setProperty('--animation-duration', '20s');
|
||||
} else if (speed === 'normal') {
|
||||
containerRef.current.style.setProperty('--animation-duration', '40s');
|
||||
} else {
|
||||
containerRef.current.style.setProperty('--animation-duration', '80s');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'scroller relative z-20 overflow-hidden -ml-4 md:-ml-[1200px] w-screen md:w-[calc(100vw+1400px)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ul
|
||||
ref={scrollerRef}
|
||||
className={cn(
|
||||
'flex min-w-full shrink-0 gap-8 py-4 w-max flex-nowrap items-start',
|
||||
start && 'animate-scroll',
|
||||
pauseOnHover && 'hover:[animation-play-state:paused]',
|
||||
)}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<li
|
||||
className="w-[310px] max-w-full relative shrink-0 md:w-[400px]"
|
||||
key={idx.toString()}
|
||||
>
|
||||
{renderItem(item, idx)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
apps/public/src/components/logo.tsx
Normal file
32
apps/public/src/components/logo.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 61 35"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('text-black dark:text-white w-16', 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>
|
||||
);
|
||||
}
|
||||
216
apps/public/src/components/navbar.tsx
Normal file
216
apps/public/src/components/navbar.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { MenuIcon, MoonIcon, SunIcon, XIcon } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FeatureCardContainer } from './feature-card';
|
||||
import { GithubButton } from './github-button';
|
||||
import { Logo } from './logo';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
text: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
text: 'Pricing',
|
||||
url: '/pricing',
|
||||
},
|
||||
{
|
||||
text: 'Supporter',
|
||||
url: '/supporter',
|
||||
},
|
||||
{
|
||||
text: 'Docs',
|
||||
url: '/docs',
|
||||
},
|
||||
{
|
||||
text: 'Articles',
|
||||
url: '/articles',
|
||||
},
|
||||
];
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const navbarRef = useRef<HTMLDivElement>(null);
|
||||
const mobileMenuRef = 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]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed top-0 z-50 w-full py-4 border-b transition-colors duration-500',
|
||||
isScrolled
|
||||
? 'bg-background border-border'
|
||||
: 'bg-background/0 border-border/0',
|
||||
)}
|
||||
ref={navbarRef}
|
||||
>
|
||||
<div className="container">
|
||||
<div className={cn('flex justify-between items-center')}>
|
||||
{/* Logo */}
|
||||
<div className="shrink-0">
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
<span className="hidden [@media(min-width:850px)]:block">
|
||||
{baseOptions().nav?.title}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row items-center gap-8">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8 text-sm">
|
||||
{LINKS?.map((link) => {
|
||||
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
|
||||
className="hidden md:flex"
|
||||
href="https://dashboard.openpanel.dev/onboarding"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<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
|
||||
ref={mobileMenuRef}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden bg-background/20 md:hidden backdrop-blur-lg fixed inset-0 p-4"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target === mobileMenuRef.current) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FeatureCardContainer className="col text-sm divide-y divide-foreground/10 gap-0 mt-12 py-4">
|
||||
{LINKS?.map((link) => {
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground text-lg font-semibold p-4 px-0"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</FeatureCardContainer>
|
||||
<div className="row gap-2 absolute top-3 right-4 items-center">
|
||||
<ThemeToggle className="flex!" />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
function ThemeToggle({ className }: { className?: string }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="naked"
|
||||
onClick={() => theme.setTheme(theme.theme === 'dark' ? 'light' : 'dark')}
|
||||
className={cn(
|
||||
'relative overflow-hidden size-8 cursor-pointer hidden md:inline',
|
||||
className,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{theme.theme === 'dark' ||
|
||||
(!theme.theme && theme.resolvedTheme === 'dark') ? (
|
||||
<motion.div
|
||||
key="moon"
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: 90, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<MoonIcon className="size-4" suppressHydrationWarning />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="sun"
|
||||
initial={{ rotate: 90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: -90, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<SunIcon className="size-4" suppressHydrationWarning />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
18
apps/public/src/components/perks.tsx
Normal file
18
apps/public/src/components/perks.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export function Perks({
|
||||
perks,
|
||||
className,
|
||||
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
|
||||
return (
|
||||
<ul className={cn('grid grid-cols-2 gap-2', className)}>
|
||||
{perks.map((perk) => (
|
||||
<li key={perk.text} className="text-sm text-muted-foreground">
|
||||
<perk.icon className="size-4 inline-block mr-2 relative -top-px" />
|
||||
{perk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
10
apps/public/src/components/personal-data-warning.tsx
Normal file
10
apps/public/src/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>
|
||||
);
|
||||
}
|
||||
64
apps/public/src/components/pricing-slider.tsx
Normal file
64
apps/public/src/components/pricing-slider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRICING } from '@openpanel/payments/prices';
|
||||
import { useState } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
|
||||
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 per month`
|
||||
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
|
||||
}
|
||||
onValueChange={(value) => setIndex(value[0])}
|
||||
/>
|
||||
|
||||
{match ? (
|
||||
<div>
|
||||
<div>
|
||||
<NumberFlow
|
||||
className="text-5xl"
|
||||
value={match.price}
|
||||
format={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}}
|
||||
locales={'en-US'}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground ml-2">/ month</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground italic opacity-100',
|
||||
match.price === 0 && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
+ VAT if applicable
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-lg">
|
||||
Contact us at{' '}
|
||||
<a className="underline" href="mailto:hello@openpanel.dev">
|
||||
hello@openpanel.dev
|
||||
</a>{' '}
|
||||
to get a custom quote.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
apps/public/src/components/section.tsx
Normal file
81
apps/public/src/components/section.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={cn('my-32 col', className)} {...props}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: 'text-3xl md:text-5xl font-semibold',
|
||||
sm: 'text-3xl md:text-4xl font-semibold',
|
||||
};
|
||||
|
||||
export function SectionHeader({
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
align,
|
||||
as = 'h2',
|
||||
variant = 'default',
|
||||
}: {
|
||||
label?: string;
|
||||
title: string | React.ReactNode;
|
||||
description?: string;
|
||||
className?: string;
|
||||
align?: 'center' | 'left';
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
variant?: keyof typeof variants;
|
||||
}) {
|
||||
const Heading = as;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col gap-4',
|
||||
align === 'center'
|
||||
? 'center-center text-center'
|
||||
: 'items-start text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label && <SectionLabel>{label}</SectionLabel>}
|
||||
<Heading className={cn(variants[variant], 'max-w-3xl leading-tight')}>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<p className={cn('text-muted-foreground max-w-3xl')}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs uppercase tracking-wider text-muted-foreground font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
70
apps/public/src/components/simple-chart.tsx
Normal file
70
apps/public/src/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/src/components/tag.tsx
Normal file
30
apps/public/src/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/src/components/toc.tsx
Normal file
34
apps/public/src/components/toc.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { TableOfContents } from 'fumadocs-core/toc';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FeatureCardContainer } from './feature-card';
|
||||
|
||||
interface Props {
|
||||
toc: TableOfContents;
|
||||
}
|
||||
|
||||
export const Toc: React.FC<Props> = ({ toc }) => {
|
||||
return (
|
||||
<FeatureCardContainer className="gap-2">
|
||||
<span className="text-lg font-semibold">Table of contents</span>
|
||||
<ul>
|
||||
{toc.map((item) => (
|
||||
<li
|
||||
key={item.url}
|
||||
className="py-1"
|
||||
style={{ marginLeft: `${(item.depth - 2) * (4 * 4)}px` }}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className="hover:underline row gap-2 items-center group/toc-item"
|
||||
title={item.title?.toString() ?? ''}
|
||||
>
|
||||
<ArrowRightIcon className="shrink-0 w-4 h-4 opacity-30 group-hover/toc-item:opacity-100 transition-opacity" />
|
||||
<span className="truncate text-sm">{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
};
|
||||
92
apps/public/src/components/twitter-card.tsx
Normal file
92
apps/public/src/components/twitter-card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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-sm">{content}</p>;
|
||||
}
|
||||
|
||||
if (Array.isArray(content) && typeof content[0] === 'string') {
|
||||
return content.map((line) => (
|
||||
<p key={line} className="text-sm">
|
||||
{line}
|
||||
</p>
|
||||
));
|
||||
}
|
||||
|
||||
return <div className="text-sm">{content}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-3xl p-8 col gap-4 bg-background-light">
|
||||
<div className="row gap-4">
|
||||
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
|
||||
{avatarUrl && (
|
||||
<Image src={avatarUrl} alt={name} width={48} height={48} />
|
||||
)}
|
||||
</div>
|
||||
<div className="col gap-4">
|
||||
<div className="col gap-2">
|
||||
<div className="">
|
||||
<span className="font-medium">{name}</span>
|
||||
{verified && (
|
||||
<div className="relative inline-block top-0.5 ml-1">
|
||||
<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">
|
||||
<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-blue-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>
|
||||
);
|
||||
}
|
||||
76
apps/public/src/components/ui/accordion.tsx
Normal file
76
apps/public/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FeatureCardBackground } from '../feature-card';
|
||||
|
||||
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 not-prose">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group relative overflow-hidden flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180 cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<FeatureCardBackground />
|
||||
{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 transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-muted-foreground"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pb-4 pt-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
65
apps/public/src/components/ui/button.tsx
Normal file
65
apps/public/src/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-px inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg 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',
|
||||
{
|
||||
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 };
|
||||
69
apps/public/src/components/ui/slider.tsx
Normal file
69
apps/public/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
setMatches(media.matches);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
const Slider = ({
|
||||
ref,
|
||||
className,
|
||||
tooltip,
|
||||
...props
|
||||
}: {
|
||||
ref?: any;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
value: number[];
|
||||
max: number;
|
||||
step: number;
|
||||
onValueChange: (value: number[]) => void;
|
||||
}) => {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<div className="text-sm text-muted-foreground mb-4">{tooltip}</div>
|
||||
)}
|
||||
<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 && !isMobile ? (
|
||||
<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 };
|
||||
51
apps/public/src/components/ui/tooltip.tsx
Normal file
51
apps/public/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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 TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
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 };
|
||||
|
||||
export const Tooltiper = ({
|
||||
children,
|
||||
content,
|
||||
delayDuration = 0,
|
||||
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
delayDuration?: number;
|
||||
}) => (
|
||||
<Tooltip delayDuration={delayDuration}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent {...props}>{content}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
6
apps/public/src/components/web-sdk-config.mdx
Normal file
6
apps/public/src/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)
|
||||
|
||||
73
apps/public/src/components/window-image.tsx
Normal file
73
apps/public/src/components/window-image.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { FeatureCardContainer } from './feature-card';
|
||||
|
||||
interface WindowImageProps {
|
||||
src?: string;
|
||||
srcDark?: string;
|
||||
srcLight?: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function WindowImage({
|
||||
src,
|
||||
srcDark,
|
||||
srcLight,
|
||||
alt,
|
||||
caption,
|
||||
className,
|
||||
}: WindowImageProps) {
|
||||
// If src is provided, use it for both (backward compatibility)
|
||||
// Otherwise, use srcDark and srcLight
|
||||
const darkSrc = srcDark || src;
|
||||
const lightSrc = srcLight || src;
|
||||
|
||||
if (!darkSrc || !lightSrc) {
|
||||
throw new Error(
|
||||
'WindowImage requires either src or both srcDark and srcLight',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureCardContainer
|
||||
className={cn([
|
||||
'overflow-hidden rounded-lg border border-border bg-background shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden p-2 bg-card/80 border col gap-2 relative">
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="size-2 rounded-full bg-red-500" />
|
||||
<div className="size-2 rounded-full bg-yellow-500" />
|
||||
<div className="size-2 rounded-full bg-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full border rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={darkSrc}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="hidden dark:block w-full h-auto"
|
||||
/>
|
||||
<Image
|
||||
src={lightSrc}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="dark:hidden w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption className="text-center text-sm text-muted-foreground max-w-lg mx-auto">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user