Axil_website/src/components/layout/Header.tsx
Vadym Samoilenko 3f6dfe36b1 feat: full CMS integration — connect all content to Payload admin panel
- Connect homepage, blog, services, header, footer to Payload CMS via local API
- Add HomePage global with 7 editorial sections (hero, painPoints, solution, whyAxil, audience, process, finalCta)
- Add ServerHeader/ServerFooter async wrappers with unstable_cache + tag-based ISR revalidation
- Rewrite blog/[slug] and services/[slug] pages to fetch from CMS with fallbacks
- Add seed script (src/lib/seed.ts) to populate all collections and globals from hardcoded defaults
- Restructure app into (site)/ route group to fix Payload admin hydration conflicts
- Make root layout a passthrough; (site)/layout.tsx owns html/body/fonts
- Restrict user creation/update/delete to admin role only
- Fix migration imports: type MigrateUpArgs/Down from @payloadcms/db-postgres
- Wrap revalidateTag dynamic imports in try/catch for seed/CLI compatibility
- Skip migrations in dev mode (Payload handles schema push automatically)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:19:44 +00:00

301 lines
10 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/Button';
import { MenuIcon, XIcon, ChevronDownIcon } from '@/components/ui/icons';
import { InteractiveMenu } from '@/components/ui/InteractiveMenu';
import { Home, Briefcase, BookOpen, Info, Mail, GraduationCap } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Navigation } from '@/payload-types';
type NavChild = { label: string; href: string; desc: string };
type NavItem = { label: string; href: string; dropdown?: NavChild[] };
const NAV_ITEMS_FALLBACK: NavItem[] = [
{ label: 'Home', href: '/' },
{
label: 'Services',
href: '/services',
dropdown: [
{
label: 'Bookkeeping',
href: '/services/bookkeeping',
desc: 'Accurate records, zero stress',
},
{ label: 'Tax Returns', href: '/services/tax-returns', desc: 'Every allowance claimed' },
{ label: 'Payroll', href: '/services/payroll', desc: 'On time, every time' },
{ label: 'VAT Returns', href: '/services/vat-returns', desc: 'MTD-compliant filing' },
],
},
{ label: 'Courses', href: '/courses' },
{ label: 'About', href: '/about' },
{ label: 'Blog', href: '/blog' },
{ label: 'Contact', href: '/contact' },
];
function buildNavItems(navData: Navigation | null | undefined): NavItem[] {
if (!navData?.items?.length) return NAV_ITEMS_FALLBACK;
return navData.items.map((item) => ({
label: item.label,
href: item.href ?? '/',
dropdown:
item.isDropdown && item.children?.length
? item.children.map((c) => ({ label: c.label, href: c.href, desc: c.description ?? '' }))
: undefined,
}));
}
const DOCK_ITEMS = [
{ label: 'Home', icon: Home, href: '/' },
{ label: 'Services', icon: Briefcase, href: '/services' },
{ label: 'Courses', icon: GraduationCap, href: '/courses' },
{ label: 'Blog', icon: BookOpen, href: '/blog' },
{ label: 'About', icon: Info, href: '/about' },
{ label: 'Contact', icon: Mail, href: '/contact' },
];
function AxilLogo({ className }: { className?: string }) {
return (
<Image
src="/logo-axil.jpg"
alt="Axil Accounting"
width={140}
height={97}
className={cn('w-auto', className)}
priority
/>
);
}
/** Nav links shared between static header and floating pill */
function NavLinks({
items,
pathname,
dropdownOpen,
setDropdownOpen,
size = 'md',
}: {
items: NavItem[];
pathname: string;
dropdownOpen: boolean;
setDropdownOpen: (v: boolean) => void;
size?: 'sm' | 'md';
}) {
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
const px = size === 'sm' ? 'px-3' : 'px-4';
return (
<>
{items.map((item) =>
item.dropdown ? (
<div
key={item.label}
className="relative"
onMouseEnter={() => setDropdownOpen(true)}
onMouseLeave={() => setDropdownOpen(false)}
>
<Link
href={item.href}
className={cn(
'rounded-pill text-charcoal hover:bg-emerald-mist flex items-center gap-1 py-2 font-medium transition-colors',
textSize,
px,
pathname === item.href ? 'text-emerald font-semibold' : '',
)}
aria-expanded={dropdownOpen}
aria-haspopup="true"
>
{item.label}
<ChevronDownIcon
size={12}
className={`transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : ''}`}
/>
</Link>
{dropdownOpen && (
<div className="rounded-hero border-charcoal/10 absolute top-full left-0 mt-2 w-60 border bg-white p-2 shadow-xl">
{item.dropdown.map((child) => (
<Link
key={child.label}
href={child.href}
className="rounded-card hover:bg-emerald-mist flex flex-col px-4 py-3 transition-colors"
onClick={() => setDropdownOpen(false)}
>
<span className="text-charcoal text-sm font-medium">{child.label}</span>
<span className="text-muted text-xs">{child.desc}</span>
</Link>
))}
</div>
)}
</div>
) : (
<Link
key={item.label}
href={item.href}
className={cn(
'rounded-pill hover:bg-emerald-mist py-2 font-medium transition-colors',
textSize,
px,
pathname === item.href ? 'text-emerald font-semibold' : 'text-charcoal',
)}
>
{item.label}
</Link>
),
)}
</>
);
}
export function Header({ navData }: { navData?: Navigation | null }) {
const navItems = buildNavItems(navData);
const lastScrollY = useRef(0);
const [atTop, setAtTop] = useState(true);
const [floatVisible, setFloatVisible] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [dropdownOpenF, setDropdownOpenF] = useState(false);
const pathname = usePathname();
useEffect(() => {
const onScroll = () => {
const y = window.scrollY;
const direction = y - lastScrollY.current;
lastScrollY.current = y;
if (y < 60) {
setAtTop(true);
setFloatVisible(false);
} else {
setAtTop(false);
setFloatVisible(direction < 0); // scroll up → show pill
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<>
{/* ─── Static header (visible only at top of page) ─── */}
<AnimatePresence>
{atTop && (
<motion.header
key="static-header"
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.2 }}
className="border-charcoal/10 fixed inset-x-0 top-0 z-50 border-b bg-white/90 shadow-sm backdrop-blur-md"
>
<div className="mx-auto flex h-18 max-w-[1440px] items-center justify-between px-4 sm:px-6 lg:px-8 xl:px-16">
<Link href="/" aria-label="Axil Accountants — Home">
<AxilLogo className="h-13" />
</Link>
<nav className="hidden items-center gap-1 lg:flex">
<NavLinks
items={navItems}
pathname={pathname}
dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen}
/>
</nav>
<div className="hidden items-center gap-3 lg:flex">
<Button size="sm" trailingArrow href="/contact">
Book Free Consultation
</Button>
</div>
<button
className="rounded-card text-charcoal hover:bg-emerald-mist flex size-10 items-center justify-center transition-colors lg:hidden"
onClick={() => setMobileOpen((v) => !v)}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
</button>
</div>
</motion.header>
)}
</AnimatePresence>
{/* ─── Floating pill nav (appears on scroll-up) ─── */}
<AnimatePresence mode="wait">
{floatVisible && (
<motion.div
key="floating-nav"
initial={{ opacity: 0, y: -100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -100 }}
transition={{ duration: 0.2 }}
className="fixed inset-x-0 top-5 z-[5000] flex justify-center px-4"
>
<div className="border-charcoal/10 flex items-center gap-3 rounded-full border bg-white/95 py-2 pr-2 pl-3 shadow-lg backdrop-blur-md">
{/* Logo */}
<Link href="/" aria-label="Home" className="mr-1 flex-shrink-0">
<AxilLogo className="h-7" />
</Link>
{/* Divider */}
<div className="bg-charcoal/10 h-5 w-px" />
{/* Nav links */}
<nav className="hidden items-center lg:flex">
<NavLinks
items={navItems}
pathname={pathname}
dropdownOpen={dropdownOpenF}
setDropdownOpen={setDropdownOpenF}
size="sm"
/>
</nav>
{/* CTA */}
<Button size="sm" trailingArrow href="/contact" className="ml-1 flex-shrink-0">
Book Free Consultation
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ─── Mobile slide-out menu ─── */}
{mobileOpen && (
<div className="fixed inset-0 z-40 flex flex-col bg-white pt-18 lg:hidden">
<nav className="flex flex-col gap-1 px-4 py-6">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className="rounded-card text-charcoal hover:bg-emerald-mist px-4 py-4 text-lg font-medium transition-colors"
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
))}
</nav>
<div className="px-4">
<Button size="lg" className="w-full justify-center" trailingArrow href="/contact">
Book Free Consultation
</Button>
</div>
</div>
)}
{/* ─── Mobile floating dock ─── */}
<div className="fixed bottom-5 left-1/2 z-40 -translate-x-1/2 lg:hidden">
<InteractiveMenu
items={DOCK_ITEMS}
accentColor="var(--emerald)"
onSelect={(_, item) => {
if (item.href) window.location.href = item.href;
}}
/>
</div>
</>
);
}