Files
pulse/components/marketing/Header.tsx

310 lines
13 KiB
TypeScript

'use client';
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/button-website';
import { cn } from '@/lib/utils';
import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon';
import { createPortal } from 'react-dom';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@/components/ui/navigation-menu';
import { LucideIcon } from 'lucide-react';
import {
BarChart3,
Eye,
Funnel,
Send,
FileText,
Puzzle,
HelpCircle,
} from 'lucide-react';
type LinkItem = {
title: string;
href: string;
icon?: LucideIcon;
description?: string;
};
const featureLinks: LinkItem[] = [
{
title: 'Dashboard',
href: '/features#dashboard',
icon: BarChart3,
description: 'Real-time traffic overview',
},
{
title: 'Visitor Insights',
href: '/features#visitors',
icon: Eye,
description: 'Browser, device & geo data',
},
{
title: 'Conversion Funnels',
href: '/features#funnels',
icon: Funnel,
description: 'Multi-step drop-off analysis',
},
{
title: 'Email Reports',
href: '/features#reports',
icon: Send,
description: 'Scheduled inbox summaries',
},
];
const resourceLinks: LinkItem[] = [
{
title: 'Installation',
href: '/installation',
icon: FileText,
description: 'Setup guides & code snippets',
},
{
title: 'Integrations',
href: '/integrations',
icon: Puzzle,
description: '75+ framework guides',
},
{
title: 'FAQ',
href: '/faq',
icon: HelpCircle,
description: 'Common questions answered',
},
];
export function Header() {
const [open, setOpen] = React.useState(false);
const scrolled = useScroll(10);
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
return (
<header
className={cn('sticky top-0 z-50 w-full border-b border-transparent', {
'border-white/[0.06]': scrolled,
})}
>
<div className={cn("absolute inset-0 -z-10 transition-opacity duration-300", scrolled ? "opacity-100 backdrop-blur-xl bg-neutral-950/60 supports-[backdrop-filter]:bg-neutral-950/50" : "opacity-0")} />
<nav className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 my-3">
<div className="flex items-center gap-5">
<Link href="/" className="hover:bg-accent rounded-md p-2 flex items-center gap-2">
<Image
src="/pulse_icon_no_margins.png"
alt="Pulse"
width={36}
height={36}
priority
className="object-contain w-8 h-8"
unoptimized
/>
<span className="text-xl font-bold text-foreground tracking-tight">
Pulse
</span>
</Link>
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{/* Features dropdown */}
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent">Features</NavigationMenuTrigger>
<NavigationMenuContent className="bg-transparent p-1 pr-1.5">
<ul className="grid w-[32rem] grid-cols-2 gap-2 rounded-md border border-white/[0.06] bg-white/[0.04] p-2">
{featureLinks.map((item, i) => (
<li key={i}>
<ListItem title={item.title} href={item.href} icon={item.icon} description={item.description} />
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
{/* Resources dropdown */}
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent">Resources</NavigationMenuTrigger>
<NavigationMenuContent className="bg-transparent p-1 pr-1.5 pb-1.5">
<div className="grid w-[32rem] grid-cols-2 gap-2">
<ul className="space-y-2 rounded-md border border-white/[0.06] bg-white/[0.04] p-2">
{resourceLinks.map((item, i) => (
<li key={i}>
<ListItem {...item} />
</li>
))}
</ul>
<div className="flex flex-col justify-center gap-3 p-4">
<p className="text-sm font-medium text-foreground">Need help?</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Questions about setup, integrations, or billing we typically respond within 24-48 hours.
</p>
<a href="mailto:support@ciphera.net" className="text-sm font-medium text-brand-orange hover:underline">
support@ciphera.net &rarr;
</a>
</div>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
{/* Pricing standalone link */}
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link href="/pricing" className="group inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none">
Pricing
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button variant="outline" asChild>
<a href="https://pulse.ciphera.net">Sign In</a>
</Button>
<Button asChild>
<a href="https://pulse.ciphera.net">Get Started</a>
</Button>
</div>
<div className="flex items-center gap-2 md:hidden">
<Button
size="icon"
variant="outline"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label="Toggle menu"
>
<MenuToggleIcon open={open} className="size-5" duration={300} />
</Button>
</div>
</nav>
<MobileMenu open={open} className="flex flex-col justify-between gap-2 overflow-y-auto">
<NavigationMenu className="max-w-full">
<div className="flex w-full flex-col gap-y-2">
<span className="text-sm">Features</span>
{featureLinks.map((link) => (
<ListItem key={link.title} title={link.title} href={link.href} icon={link.icon} description={link.description} />
))}
<span className="text-sm">Resources</span>
{resourceLinks.map((link) => (
<ListItem key={link.title} {...link} />
))}
<Link
href="/pricing"
className="flex flex-row gap-x-2 rounded-sm p-2 transition-colors hover:bg-white/[0.06]"
>
<div className="flex aspect-square size-12 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.05] shadow-sm p-2">
<BarChart3 className="text-foreground size-5" />
</div>
<div className="flex flex-col items-start justify-center">
<span className="text-sm font-medium">Pricing</span>
<span className="text-muted-foreground text-xs">Plans & billing</span>
</div>
</Link>
</div>
</NavigationMenu>
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full bg-transparent" asChild>
<a href="https://pulse.ciphera.net">
Sign In
</a>
</Button>
<Button className="w-full" asChild>
<a href="https://pulse.ciphera.net">
Get Started
</a>
</Button>
</div>
</MobileMenu>
</header>
);
}
type MobileMenuProps = React.ComponentProps<'div'> & {
open: boolean;
};
function MobileMenu({ open, children, className, ...props }: MobileMenuProps) {
if (!open || typeof window === 'undefined') return null;
return createPortal(
<div
id="mobile-menu"
className={cn(
'bg-background/95 supports-[backdrop-filter]:bg-background/50 backdrop-blur-lg',
'fixed top-16 right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden border-y md:hidden',
)}
>
<div
data-slot={open ? 'open' : 'closed'}
className={cn(
'data-[slot=open]:animate-in data-[slot=open]:zoom-in-95 ease-out',
'size-full p-4',
className,
)}
{...props}
>
{children}
</div>
</div>,
document.body,
);
}
function ListItem({
title,
description,
icon: Icon,
className,
href,
...props
}: React.ComponentProps<typeof NavigationMenuLink> & LinkItem) {
return (
<NavigationMenuLink className={cn('w-full flex flex-row gap-x-2 data-[active=true]:focus:bg-white/[0.06] data-[active=true]:hover:bg-white/[0.06] data-[active=true]:text-accent-foreground hover:bg-white/[0.06] hover:text-accent-foreground focus:bg-white/[0.06] focus:text-accent-foreground rounded-sm p-2 transition-colors', className)} {...props} asChild>
<Link href={href || '#'}>
<div className="flex aspect-square size-12 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.05] shadow-sm p-2">
{Icon ? (
<Icon className="text-foreground size-5" />
) : null}
</div>
<div className="flex flex-col items-start justify-center">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
</Link>
</NavigationMenuLink>
);
}
function useScroll(threshold: number) {
const [scrolled, setScrolled] = React.useState(false);
const onScroll = React.useCallback(() => {
setScrolled(window.scrollY > threshold);
}, [threshold]);
React.useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
React.useEffect(() => {
onScroll();
}, [onScroll]);
return scrolled;
}